Compare commits
85 Commits
releases/v
...
vzt/prepar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4e59e3d45 | ||
|
|
c2b96b53cd | ||
|
|
6f52eddca2 | ||
|
|
dc5cd4d17f | ||
|
|
b4962e1861 | ||
|
|
2f243ca4fa | ||
|
|
c96389a7ae | ||
|
|
ba38da7905 | ||
|
|
6961848b51 | ||
|
|
fac78884c8 | ||
|
|
2c0496a719 | ||
|
|
483f83267a | ||
|
|
0ee5bc7530 | ||
|
|
fee9db6b39 | ||
|
|
1824b2f006 | ||
|
|
0f3110274c | ||
|
|
80f581e4b5 | ||
|
|
74df766d96 | ||
|
|
df80909445 | ||
|
|
e3aa72700f | ||
|
|
6a721fb6aa | ||
|
|
7ee331322f | ||
|
|
9fb38d43e1 | ||
|
|
bdf1f8ceff | ||
|
|
7c3deef5f9 | ||
|
|
3048b822db | ||
|
|
564bf5b01f | ||
|
|
0ff92dd768 | ||
|
|
1850a6b487 | ||
|
|
08a0af8ec3 | ||
|
|
d11f2d1dac | ||
|
|
2c12b97549 | ||
|
|
211132e91f | ||
|
|
13f58eec61 | ||
|
|
f9847b316a | ||
|
|
438a01224c | ||
|
|
ba891d4bf2 | ||
|
|
909c7e01f1 | ||
|
|
1c443d83da | ||
|
|
867fee7815 | ||
|
|
ee4b4919bf | ||
|
|
13f110716c | ||
|
|
7914f19c4d | ||
|
|
25aa72f51b | ||
|
|
2fa8a509ca | ||
|
|
f30ae37660 | ||
|
|
93b787fdef | ||
|
|
29afdd3b50 | ||
|
|
c03c0b6bbe | ||
|
|
cb2930c65f | ||
|
|
65a7f5e592 | ||
|
|
a8d5f2a285 | ||
|
|
f5fc2382af | ||
|
|
596d8007a1 | ||
|
|
c9ae724dfd | ||
|
|
40b6ef7b57 | ||
|
|
a4866d442c | ||
|
|
10084cd57d | ||
|
|
d6b7427345 | ||
|
|
96de656797 | ||
|
|
c0724d8845 | ||
|
|
3a62b7cf63 | ||
|
|
43fd9cdb84 | ||
|
|
73195c1d43 | ||
|
|
85e0e789db | ||
|
|
39cf694bee | ||
|
|
39b7248332 | ||
|
|
a5e5c78980 | ||
|
|
7d16183347 | ||
|
|
0cbceff209 | ||
|
|
fec5c39dcc | ||
|
|
a525a84c53 | ||
|
|
6483669c68 | ||
|
|
06962f2427 | ||
|
|
3e2909a6b2 | ||
|
|
734cd437f8 | ||
|
|
555e0c6a63 | ||
|
|
0a309926fa | ||
|
|
a51c02d593 | ||
|
|
d36634f96f | ||
|
|
a8494ee438 | ||
|
|
904025a654 | ||
|
|
581a835fb5 | ||
|
|
cf1a288161 | ||
|
|
2792fede48 |
118
.github/workflows/acceptance-test.yml
vendored
Normal file
118
.github/workflows/acceptance-test.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
name: Acceptance test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
secret:
|
||||
required: true
|
||||
type: string
|
||||
secret-in-section:
|
||||
required: true
|
||||
type: string
|
||||
multiline-secret:
|
||||
required: true
|
||||
type: string
|
||||
export-env:
|
||||
required: true
|
||||
type: boolean
|
||||
version:
|
||||
required: false
|
||||
type: string
|
||||
default: "latest"
|
||||
os:
|
||||
required: true
|
||||
type: string
|
||||
default: "ubuntu-latest"
|
||||
auth:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
acceptance-test:
|
||||
runs-on: ${{ inputs.os }}
|
||||
steps:
|
||||
- name: Base checkout
|
||||
uses: actions/checkout@v4
|
||||
if: |
|
||||
github.event_name != 'repository_dispatch' &&
|
||||
(
|
||||
github.ref == 'refs/heads/main' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
)
|
||||
)
|
||||
- name: Fork based /ok-to-test checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.client_payload.pull_request.head.sha }}
|
||||
if: |
|
||||
github.event_name == 'repository_dispatch' &&
|
||||
github.event.client_payload.slash_command.args.named.sha != '' &&
|
||||
contains(
|
||||
github.event.client_payload.pull_request.head.sha,
|
||||
github.event.client_payload.slash_command.args.named.sha
|
||||
)
|
||||
- name: Launch 1Password Connect instance
|
||||
if: ${{ inputs.auth == 'connect' }}
|
||||
env:
|
||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||
run: |
|
||||
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
||||
docker compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
|
||||
- name: Configure Service account
|
||||
if: ${{ inputs.auth == 'service-account' }}
|
||||
uses: ./configure
|
||||
with:
|
||||
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
- name: Verify Service Account env var is set
|
||||
if: ${{ inputs.auth == 'service-account' }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "${OP_SERVICE_ACCOUNT_TOKEN}" ]; then
|
||||
echo "OP_SERVICE_ACCOUNT_TOKEN environment variable is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Configure 1Password Connect
|
||||
if: ${{ inputs.auth == 'connect' }}
|
||||
uses: ./configure # 1password/load-secrets-action/configure@<version>
|
||||
with:
|
||||
connect-host: http://localhost:8080
|
||||
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
- name: Verify Connect env vars are set
|
||||
if: ${{ inputs.auth == 'connect' }}
|
||||
run: |
|
||||
if [ -z "$OP_CONNECT_HOST" ] || [ -z "$OP_CONNECT_TOKEN" ]; then
|
||||
echo "OP_CONNECT_HOST or OP_CONNECT_TOKEN environment variables are not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Load secrets
|
||||
id: load_secrets
|
||||
uses: ./ # 1password/load-secrets-action@<version>
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
export-env: ${{ inputs.export-env }}
|
||||
env:
|
||||
SECRET: ${{ inputs.secret }}
|
||||
SECRET_IN_SECTION: ${{ inputs.secret-in-section }}
|
||||
MULTILINE_SECRET: ${{ inputs.multiline-secret }}
|
||||
OP_ENV_FILE: ./tests/.env.tpl
|
||||
- name: Assert test secret values [step output]
|
||||
if: ${{ !inputs.export-env }}
|
||||
env:
|
||||
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
|
||||
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
|
||||
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
|
||||
OP_ENV_FILE: ./tests/.env.tpl
|
||||
run: ./tests/assert-env-set.sh
|
||||
- name: Assert test secret values [exported env]
|
||||
if: ${{ inputs.export-env }}
|
||||
run: ./tests/assert-env-set.sh
|
||||
- name: Remove secrets [exported env]
|
||||
if: ${{ inputs.export-env }}
|
||||
uses: ./ # 1password/load-secrets-action@<version>
|
||||
with:
|
||||
unset-previous: true
|
||||
- name: Assert removed secrets [exported env]
|
||||
if: ${{ inputs.export-env }}
|
||||
run: ./tests/assert-env-unset.sh
|
||||
160
.github/workflows/e2e-tests.yml
vendored
Normal file
160
.github/workflows/e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
# For local testing with: act push -W .github/workflows/e2e-tests.yml
|
||||
push:
|
||||
branches-ignore:
|
||||
- "**" # Never runs on GitHub, only locally with act
|
||||
|
||||
# For test.yml to call this workflow
|
||||
workflow_call:
|
||||
secrets:
|
||||
OP_CONNECT_CREDENTIALS:
|
||||
required: true
|
||||
OP_CONNECT_TOKEN:
|
||||
required: true
|
||||
OP_SERVICE_ACCOUNT_TOKEN:
|
||||
required: true
|
||||
VAULT:
|
||||
description: "1Password vault name or UUID"
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
test-service-account:
|
||||
name: Service Account (${{ matrix.os }}, ${{ matrix.version }}, export-env=${{ matrix.export-env }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
version: [latest, 2.30.0]
|
||||
export-env: [true, false]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate .env.tpl
|
||||
shell: bash
|
||||
run: |
|
||||
echo "FILE_SECRET=op://${{ secrets.VAULT }}/test-secret/password" > tests/.env.tpl
|
||||
echo "FILE_SECRET_IN_SECTION=op://${{ secrets.VAULT }}/test-secret/test-section/password" >> tests/.env.tpl
|
||||
echo "FILE_MULTILINE_SECRET=op://${{ secrets.VAULT }}/multiline-secret/notesPlain" >> tests/.env.tpl
|
||||
|
||||
- name: Configure Service account
|
||||
uses: ./configure
|
||||
with:
|
||||
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
|
||||
- name: Load secrets
|
||||
id: load_secrets
|
||||
uses: ./
|
||||
with:
|
||||
version: ${{ matrix.version }}
|
||||
export-env: ${{ matrix.export-env }}
|
||||
env:
|
||||
SECRET: op://${{ secrets.VAULT }}/test-secret/password
|
||||
SECRET_IN_SECTION: op://${{ secrets.VAULT }}/test-secret/test-section/password
|
||||
MULTILINE_SECRET: op://${{ secrets.VAULT }}/multiline-secret/notesPlain
|
||||
OP_ENV_FILE: ./tests/.env.tpl
|
||||
|
||||
- name: Assert test secret values [step output]
|
||||
if: ${{ !matrix.export-env }}
|
||||
shell: bash
|
||||
env:
|
||||
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
|
||||
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
|
||||
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
|
||||
FILE_SECRET: ${{ steps.load_secrets.outputs.FILE_SECRET }}
|
||||
FILE_SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.FILE_SECRET_IN_SECTION }}
|
||||
FILE_MULTILINE_SECRET: ${{ steps.load_secrets.outputs.FILE_MULTILINE_SECRET }}
|
||||
run: ./tests/assert-env-set.sh
|
||||
|
||||
- name: Assert test secret values [exported env]
|
||||
if: ${{ matrix.export-env }}
|
||||
shell: bash
|
||||
run: ./tests/assert-env-set.sh
|
||||
|
||||
- name: Remove secrets [exported env]
|
||||
if: ${{ matrix.export-env }}
|
||||
uses: ./
|
||||
with:
|
||||
unset-previous: true
|
||||
|
||||
- name: Assert removed secrets [exported env]
|
||||
if: ${{ matrix.export-env }}
|
||||
shell: bash
|
||||
run: ./tests/assert-env-unset.sh
|
||||
|
||||
test-connect:
|
||||
name: Connect (ubuntu-latest, ${{ matrix.version }}, export-env=${{ matrix.export-env }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
version: [latest, 2.30.0]
|
||||
export-env: [true, false]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate .env.tpl
|
||||
run: |
|
||||
mkdir -p tests
|
||||
echo "FILE_SECRET=op://${{ secrets.VAULT }}/test-secret/password" > tests/.env.tpl
|
||||
echo "FILE_SECRET_IN_SECTION=op://${{ secrets.VAULT }}/test-secret/test-section/password" >> tests/.env.tpl
|
||||
echo "FILE_MULTILINE_SECRET=op://${{ secrets.VAULT }}/multiline-secret/notesPlain" >> tests/.env.tpl
|
||||
|
||||
- name: Launch 1Password Connect instance
|
||||
env:
|
||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||
run: |
|
||||
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
||||
docker compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
|
||||
|
||||
- name: Configure 1Password Connect
|
||||
uses: ./configure
|
||||
with:
|
||||
connect-host: http://localhost:8080
|
||||
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
|
||||
- name: Load secrets
|
||||
id: load_secrets
|
||||
uses: ./
|
||||
with:
|
||||
version: ${{ matrix.version }}
|
||||
export-env: ${{ matrix.export-env }}
|
||||
env:
|
||||
SECRET: op://${{ secrets.VAULT }}/test-secret/password
|
||||
SECRET_IN_SECTION: op://${{ secrets.VAULT }}/test-secret/test-section/password
|
||||
MULTILINE_SECRET: op://${{ secrets.VAULT }}/multiline-secret/notesPlain
|
||||
OP_ENV_FILE: ./tests/.env.tpl
|
||||
|
||||
- name: Assert test secret values [step output]
|
||||
if: ${{ !matrix.export-env }}
|
||||
env:
|
||||
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
|
||||
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
|
||||
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
|
||||
FILE_SECRET: ${{ steps.load_secrets.outputs.FILE_SECRET }}
|
||||
FILE_SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.FILE_SECRET_IN_SECTION }}
|
||||
FILE_MULTILINE_SECRET: ${{ steps.load_secrets.outputs.FILE_MULTILINE_SECRET }}
|
||||
run: ./tests/assert-env-set.sh
|
||||
|
||||
- name: Assert test secret values [exported env]
|
||||
if: ${{ matrix.export-env }}
|
||||
run: ./tests/assert-env-set.sh
|
||||
|
||||
- name: Remove secrets [exported env]
|
||||
if: ${{ matrix.export-env }}
|
||||
uses: ./
|
||||
with:
|
||||
unset-previous: true
|
||||
|
||||
- name: Assert removed secrets [exported env]
|
||||
if: ${{ matrix.export-env }}
|
||||
run: ./tests/assert-env-unset.sh
|
||||
36
.github/workflows/lint-and-test.yml
vendored
Normal file
36
.github/workflows/lint-and-test.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Lint and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/action-shellcheck@2.0.0
|
||||
with:
|
||||
ignore_paths: >-
|
||||
.husky
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
|
||||
- name: Check lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
@@ -1,4 +1,7 @@
|
||||
on: pull_request
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
name: Lint
|
||||
|
||||
jobs:
|
||||
@@ -11,3 +14,16 @@ jobs:
|
||||
with:
|
||||
ignore_paths: >-
|
||||
.husky
|
||||
- name: Setup Node.js
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- name: Install Dependencies
|
||||
id: install
|
||||
run: npm ci
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
- name: Check lint
|
||||
run: npm run lint
|
||||
|
||||
25
.github/workflows/ok-to-test.yml
vendored
Normal file
25
.github/workflows/ok-to-test.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Write comments "/ok-to-test sha=<hash>" on a pull request. This will emit a repository_dispatch event.
|
||||
name: Ok To Test
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
ok-to-test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write # For adding reactions to the pull request comments
|
||||
contents: write # For executing the repository_dispatch event
|
||||
# Only run for PRs, not issue comments
|
||||
if: ${{ github.event.issue.pull_request }}
|
||||
steps:
|
||||
- name: Slash Command Dispatch
|
||||
uses: peter-evans/slash-command-dispatch@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reaction-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-type: pull-request
|
||||
commands: ok-to-test
|
||||
# The repository permission level required by the user to dispatch commands. Only allows 1Password collaborators to run this.
|
||||
permission: write
|
||||
13
.github/workflows/pr-check-signed-commits.yml
vendored
Normal file
13
.github/workflows/pr-check-signed-commits.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: Check signed commits in PR
|
||||
on: pull_request_target
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Check signed commits in PR
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check signed commits in PR
|
||||
uses: 1Password/check-signed-commits-action@v1
|
||||
114
.github/workflows/test-e2e.yml
vendored
Normal file
114
.github/workflows/test-e2e.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: &ignore_paths
|
||||
- "docs/**"
|
||||
- "config/**"
|
||||
- "*.md"
|
||||
- ".gitignore"
|
||||
- "LICENSE"
|
||||
pull_request:
|
||||
paths-ignore: *ignore_paths
|
||||
repository_dispatch:
|
||||
types: [ok-to-test-command]
|
||||
|
||||
concurrency:
|
||||
group: >-
|
||||
${{ github.event_name == 'pull_request' &&
|
||||
format('e2e-{0}', github.event.pull_request.head.ref) ||
|
||||
format('e2e-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-external-pr:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
condition: ${{ steps.check.outputs.condition }}
|
||||
steps:
|
||||
- name: Check if PR is from external contributor
|
||||
id: check
|
||||
run: |
|
||||
echo "Event name: ${{ github.event_name }}"
|
||||
echo "Repository: ${{ github.repository }}"
|
||||
|
||||
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||
# For pull_request events, check if PR is from external fork
|
||||
echo "PR head repo: ${{ github.event.pull_request.head.repo.full_name }}"
|
||||
if [ "${{ github.actor }}" == "dependabot[bot]" ]; then
|
||||
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||
echo "Setting condition=skip (Dependabot PR)"
|
||||
elif [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
|
||||
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||
echo "Setting condition=skip (external fork PR creation)"
|
||||
else
|
||||
echo "condition=pr-creation-maintainer" >> $GITHUB_OUTPUT
|
||||
echo "Setting condition=pr-creation-maintainer (internal PR creation)"
|
||||
fi
|
||||
elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then
|
||||
# For repository_dispatch events (ok-to-test), check if sha matches
|
||||
SHA_PARAM="${{ github.event.client_payload.slash_command.args.named.sha }}"
|
||||
PR_HEAD_SHA="${{ github.event.client_payload.pull_request.head.sha }}"
|
||||
|
||||
echo "Checking dispatch event conditions..."
|
||||
echo "SHA from command: $SHA_PARAM"
|
||||
echo "PR head SHA: $PR_HEAD_SHA"
|
||||
|
||||
if [ -n "$SHA_PARAM" ] && [[ "$PR_HEAD_SHA" == *"$SHA_PARAM"* ]]; then
|
||||
echo "condition=dispatch-event" >> $GITHUB_OUTPUT
|
||||
echo "Setting condition=dispatch-event (sha matches)"
|
||||
else
|
||||
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||
echo "Setting condition=skip (sha does not match or empty)"
|
||||
fi
|
||||
elif [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref_name }}" == "main" ]; then
|
||||
echo "condition=push-to-main" >> $GITHUB_OUTPUT
|
||||
echo "Setting condition=push-to-main (push to main)"
|
||||
else
|
||||
# Unknown event type
|
||||
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||
echo "Setting condition=skip (unknown event type: ${{ github.event_name }})"
|
||||
fi
|
||||
|
||||
e2e:
|
||||
needs: check-external-pr
|
||||
if: |
|
||||
(needs.check-external-pr.outputs.condition == 'pr-creation-maintainer')
|
||||
||
|
||||
(needs.check-external-pr.outputs.condition == 'dispatch-event')
|
||||
||
|
||||
needs.check-external-pr.outputs.condition == 'push-to-main'
|
||||
uses: ./.github/workflows/e2e-tests.yml
|
||||
secrets:
|
||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
VAULT: ${{ secrets.VAULT }}
|
||||
|
||||
# Post comment on fork PRs after /ok-to-test
|
||||
comment-pr:
|
||||
needs: [check-external-pr, e2e]
|
||||
runs-on: ubuntu-latest
|
||||
if: always() && needs.check-external-pr.outputs.condition == 'dispatch-event'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Create URL to the run output
|
||||
id: vars
|
||||
run: echo "run-url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create comment on PR
|
||||
uses: peter-evans/create-or-update-comment@v5
|
||||
with:
|
||||
issue-number: ${{ github.event.client_payload.pull_request.number }}
|
||||
body: |
|
||||
${{
|
||||
needs.e2e.result == 'success' && '✅ E2E tests passed.' ||
|
||||
needs.e2e.result == 'failure' && '❌ E2E tests failed.' ||
|
||||
'⚠️ E2E tests completed.'
|
||||
}}
|
||||
|
||||
[View test run output][1]
|
||||
|
||||
[1]: ${{ steps.vars.outputs.run-url }}
|
||||
92
.github/workflows/test-fork.yml
vendored
Normal file
92
.github/workflows/test-fork.yml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [ok-to-test-command]
|
||||
name: Run acceptance tests [fork]
|
||||
|
||||
jobs:
|
||||
test-with-output-secrets:
|
||||
if: |
|
||||
github.event_name == 'repository_dispatch' &&
|
||||
github.event.client_payload.slash_command.args.named.sha != '' &&
|
||||
contains(
|
||||
github.event.client_payload.pull_request.head.sha,
|
||||
github.event.client_payload.slash_command.args.named.sha
|
||||
)
|
||||
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
secret: op://acceptance-tests/test-secret/password
|
||||
secret-in-section: op://acceptance-tests/test-secret/test-section/password
|
||||
multiline-secret: op://acceptance-tests/multiline-secret/notesPlain
|
||||
export-env: false
|
||||
test-with-export-env:
|
||||
if: |
|
||||
github.event_name == 'repository_dispatch' &&
|
||||
github.event.client_payload.slash_command.args.named.sha != '' &&
|
||||
contains(
|
||||
github.event.client_payload.pull_request.head.sha,
|
||||
github.event.client_payload.slash_command.args.named.sha
|
||||
)
|
||||
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
secret: op://acceptance-tests/test-secret/password
|
||||
secret-in-section: op://acceptance-tests/test-secret/test-section/password
|
||||
multiline-secret: op://acceptance-tests/multiline-secret/notesPlain
|
||||
export-env: true
|
||||
test-references-with-ids:
|
||||
if: |
|
||||
github.event_name == 'repository_dispatch' &&
|
||||
github.event.client_payload.slash_command.args.named.sha != '' &&
|
||||
contains(
|
||||
github.event.client_payload.pull_request.head.sha,
|
||||
github.event.client_payload.slash_command.args.named.sha
|
||||
)
|
||||
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
secret: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
|
||||
secret-in-section: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/Section_tco6nsqycj6jcbyx63h5isxcny/doxu3mhkozcznnk5vjrkpdqayy
|
||||
multiline-secret: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
|
||||
export-env: false
|
||||
update-checks:
|
||||
# required permissions for updating the status of the pull request checks
|
||||
permissions:
|
||||
pull-requests: write
|
||||
checks: write
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() }}
|
||||
strategy:
|
||||
matrix:
|
||||
job-name:
|
||||
[
|
||||
test-with-output-secrets,
|
||||
test-with-export-env,
|
||||
test-references-with-ids,
|
||||
]
|
||||
needs:
|
||||
[test-with-output-secrets, test-with-export-env, test-references-with-ids]
|
||||
steps:
|
||||
- uses: actions/github-script@v6
|
||||
env:
|
||||
job: ${{ matrix.job-name }}
|
||||
ref: ${{ github.event.client_payload.pull_request.head.sha }}
|
||||
conclusion: ${{ needs[format('{0}', matrix.job-name )].result }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { data: checks } = await github.rest.checks.listForRef({
|
||||
...context.repo,
|
||||
ref: process.env.ref
|
||||
});
|
||||
|
||||
const check = checks.check_runs.filter(c => c.name === process.env.job);
|
||||
|
||||
const { data: result } = await github.rest.checks.update({
|
||||
...context.repo,
|
||||
check_run_id: check[0].id,
|
||||
status: 'completed',
|
||||
conclusion: process.env.conclusion
|
||||
});
|
||||
|
||||
return result;
|
||||
188
.github/workflows/test.yml
vendored
188
.github/workflows/test.yml
vendored
@@ -1,136 +1,100 @@
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
name: Run acceptance tests
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
test-with-output-secrets:
|
||||
if: |
|
||||
github.ref == 'refs/heads/main' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
)
|
||||
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||
secrets: inherit
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
version: [latest, latest-beta, 2.30.0, 2.30.0-beta.03]
|
||||
auth: [connect, service-account]
|
||||
exclude:
|
||||
- os: macos-latest
|
||||
auth: connect
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Launch 1Password Connect instance
|
||||
if: ${{ matrix.auth == 'connect' }}
|
||||
env:
|
||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||
run: |
|
||||
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
||||
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
|
||||
- name: Configure Service account
|
||||
if: ${{ matrix.auth == 'service-account' }}
|
||||
uses: ./configure
|
||||
with:
|
||||
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
- name: Configure 1Password Connect
|
||||
if: ${{ matrix.auth == 'connect' }}
|
||||
uses: ./configure # 1password/load-secrets-action/configure@<version>
|
||||
with:
|
||||
connect-host: localhost:8080
|
||||
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
- name: Load secrets
|
||||
id: load_secrets
|
||||
uses: ./ # 1password/load-secrets-action@<version>
|
||||
with:
|
||||
export-env: false
|
||||
env:
|
||||
SECRET: op://acceptance-tests/test-secret/password
|
||||
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
|
||||
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
|
||||
- name: Assert test secret values
|
||||
env:
|
||||
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
|
||||
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
|
||||
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
|
||||
run: ./tests/assert-env-set.sh
|
||||
- os: windows-latest
|
||||
auth: connect
|
||||
with:
|
||||
os: ${{ matrix.os }}
|
||||
version: ${{ matrix.version }}
|
||||
auth: ${{ matrix.auth }}
|
||||
secret: op://acceptance-tests/test-secret/password
|
||||
secret-in-section: op://acceptance-tests/test-secret/test-section/password
|
||||
multiline-secret: op://acceptance-tests/multiline-secret/notesPlain
|
||||
export-env: false
|
||||
|
||||
test-with-export-env:
|
||||
if: |
|
||||
github.ref == 'refs/heads/main' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
)
|
||||
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||
secrets: inherit
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
version: [latest, latest-beta, 2.30.0, 2.30.0-beta.03]
|
||||
auth: [connect, service-account]
|
||||
exclude:
|
||||
- os: macos-latest
|
||||
auth: connect
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Launch 1Password Connect instance
|
||||
if: ${{ matrix.auth == 'connect' }}
|
||||
env:
|
||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||
run: |
|
||||
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
||||
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
|
||||
- name: Configure Service account
|
||||
if: ${{ matrix.auth == 'service-account' }}
|
||||
uses: ./configure
|
||||
with:
|
||||
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
- name: Configure 1Password Connect
|
||||
if: ${{ matrix.auth == 'connect' }}
|
||||
uses: ./configure # 1password/load-secrets-action/configure@<version>
|
||||
with:
|
||||
connect-host: http://localhost:8080
|
||||
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
- name: Load secrets
|
||||
id: load_secrets
|
||||
uses: ./ # 1password/load-secrets-action@<version>
|
||||
env:
|
||||
SECRET: op://acceptance-tests/test-secret/password
|
||||
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
|
||||
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
|
||||
- name: Assert test secret values
|
||||
run: ./tests/assert-env-set.sh
|
||||
- name: Remove secrets
|
||||
uses: ./ # 1password/load-secrets-action@<version>
|
||||
with:
|
||||
unset-previous: true
|
||||
- name: Assert removed secrets
|
||||
run: ./tests/assert-env-unset.sh
|
||||
- os: windows-latest
|
||||
auth: connect
|
||||
with:
|
||||
os: ${{ matrix.os }}
|
||||
version: ${{ matrix.version }}
|
||||
auth: ${{ matrix.auth }}
|
||||
secret: op://acceptance-tests/test-secret/password
|
||||
secret-in-section: op://acceptance-tests/test-secret/test-section/password
|
||||
multiline-secret: op://acceptance-tests/multiline-secret/notesPlain
|
||||
export-env: true
|
||||
|
||||
test-references-with-ids:
|
||||
if: |
|
||||
github.ref == 'refs/heads/main' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
)
|
||||
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||
secrets: inherit
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
version: [latest, latest-beta, 2.30.0, 2.30.0-beta.03]
|
||||
auth: [connect, service-account]
|
||||
exclude:
|
||||
- os: macos-latest
|
||||
auth: connect
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Launch 1Password Connect instance
|
||||
if: ${{ matrix.auth == 'connect' }}
|
||||
env:
|
||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||
run: |
|
||||
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
||||
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
|
||||
- name: Configure Service account
|
||||
if: ${{ matrix.auth == 'service-account' }}
|
||||
uses: ./configure
|
||||
with:
|
||||
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
- name: Configure 1Password Connect
|
||||
if: ${{ matrix.auth == 'connect' }}
|
||||
uses: ./configure # 1password/load-secrets-action/configure@<version>
|
||||
with:
|
||||
connect-host: http://localhost:8080
|
||||
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
- name: Load secrets
|
||||
id: load_secrets
|
||||
uses: ./ # 1password/load-secrets-action@<version>
|
||||
with:
|
||||
export-env: false
|
||||
env:
|
||||
SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
|
||||
SECRET_IN_SECTION: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/Section_tco6nsqycj6jcbyx63h5isxcny/doxu3mhkozcznnk5vjrkpdqayy
|
||||
MULTILINE_SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
|
||||
- name: Assert test secret values
|
||||
env:
|
||||
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
|
||||
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
|
||||
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
|
||||
run: ./tests/assert-env-set.sh
|
||||
- os: windows-latest
|
||||
auth: connect
|
||||
with:
|
||||
os: ${{ matrix.os }}
|
||||
version: ${{ matrix.version }}
|
||||
auth: ${{ matrix.auth }}
|
||||
secret: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
|
||||
secret-in-section: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/Section_tco6nsqycj6jcbyx63h5isxcny/doxu3mhkozcznnk5vjrkpdqayy
|
||||
multiline-secret: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
|
||||
export-env: false
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
coverage/
|
||||
node_modules/
|
||||
.idea/
|
||||
1password-credentials.json
|
||||
|
||||
54
CONTRIBUTING.md
Normal file
54
CONTRIBUTING.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing to the 1Password load-secrets-action project 👋! Before you start, please take a moment to read through this guide to understand our contribution process.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests can be run with `npm run test`.
|
||||
|
||||
After following the steps below for signing commits, you can test against your PR with these steps:
|
||||
|
||||
1. Create or use an existing repo to run the `load-secrets` GitHub Action.
|
||||
2. In a workflow yaml file that uses the GitHub Action, modify the `uses: 1Password/load-secrets-action` line to be
|
||||
|
||||
```yaml
|
||||
uses: 1Password/load-secrets-action@<branch-name>
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```yaml
|
||||
uses: 1Password/load-secrets-action@<commit-hash>
|
||||
```
|
||||
|
||||
3. Trigger the action, which now includes your changes.
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
If applicable, update the [README.md](./README.md) to reflect any changes introduced by the new code.
|
||||
|
||||
## Sign your commits
|
||||
|
||||
To get your PR merged, we require you to sign your commits.
|
||||
|
||||
### Sign commits with 1Password
|
||||
|
||||
You can also sign commits using 1Password, which lets you sign commits with biometrics without the signing key leaving the local 1Password process.
|
||||
|
||||
Learn how to use [1Password to sign your commits](https://developer.1password.com/docs/ssh/git-commit-signing/).
|
||||
|
||||
### Sign commits with ssh-agent
|
||||
|
||||
Follow the steps below to set up commit signing with `ssh-agent`:
|
||||
|
||||
1. [Generate an SSH key and add it to ssh-agent](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent)
|
||||
2. [Add the SSH key to your GitHub account](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account)
|
||||
3. [Configure git to use your SSH key for commits signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key#telling-git-about-your-ssh-key)
|
||||
|
||||
### Sign commits with gpg
|
||||
|
||||
Follow the steps below to set up commit signing with `gpg`:
|
||||
|
||||
1. [Generate a GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key)
|
||||
2. [Add the GPG key to your GitHub account](https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account)
|
||||
3. [Configure git to use your GPG key for commits signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key#telling-git-about-your-gpg-key)
|
||||
36
README.md
36
README.md
@@ -23,22 +23,48 @@ Read more on the [1Password Developer Portal](https://developer.1password.com/do
|
||||
|
||||
## ✨ Quickstart
|
||||
|
||||
### Export secrets as a step's output (recommended)
|
||||
|
||||
```yml
|
||||
on: push
|
||||
jobs:
|
||||
hello-world:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Load secret
|
||||
uses: 1password/load-secrets-action@v1
|
||||
id: load_secrets
|
||||
uses: 1password/load-secrets-action@v3
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
SECRET: op://app-cicd/hello-world/secret
|
||||
OP_ENV_FILE: "./path/to/.env.tpl" # see tests/.env.tpl for example
|
||||
|
||||
- name: Print masked secret
|
||||
run: 'echo "Secret: ${{ steps.load_secrets.outputs.SECRET }}"'
|
||||
# Prints: Secret: ***
|
||||
```
|
||||
|
||||
### Export secrets as env variables
|
||||
|
||||
```yml
|
||||
on: push
|
||||
jobs:
|
||||
hello-world:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Load secret
|
||||
uses: 1password/load-secrets-action@v3
|
||||
with:
|
||||
# Export loaded secrets as environment variables
|
||||
export-env: true
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
SECRET: op://app-cicd/hello-world/secret
|
||||
OP_ENV_FILE: "./path/to/.env.tpl" # see tests/.env.tpl for example
|
||||
|
||||
- name: Print masked secret
|
||||
run: 'echo "Secret: $SECRET"'
|
||||
@@ -48,13 +74,11 @@ jobs:
|
||||
## 💙 Community & Support
|
||||
|
||||
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
|
||||
- Join the [Developer Slack workspace](https://join.slack.com/t/1password-devs/shared_invite/zt-1halo11ps-6o9pEv96xZ3LtX_VE0fJQA).
|
||||
- Join the [Developer Slack workspace](https://developer.1password.com/joinslack).
|
||||
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
1Password requests you practice responsible disclosure if you discover a vulnerability.
|
||||
|
||||
Please file requests via [**BugCrowd**](https://bugcrowd.com/agilebits).
|
||||
|
||||
For information about security practices, please visit the [1Password Bug Bounty Program](https://bugcrowd.com/agilebits).
|
||||
Please file requests by sending an email to bugbounty@agilebits.com.
|
||||
|
||||
@@ -10,7 +10,10 @@ inputs:
|
||||
default: "false"
|
||||
export-env:
|
||||
description: Export the secrets as environment variables
|
||||
default: "true"
|
||||
default: "false"
|
||||
version:
|
||||
description: Specify which 1Password CLI version to install. Defaults to "latest".
|
||||
default: "latest"
|
||||
runs:
|
||||
using: "node16"
|
||||
using: "node20"
|
||||
main: "dist/index.js"
|
||||
|
||||
@@ -11,7 +11,16 @@ const jestConfig = {
|
||||
testEnvironment: "node",
|
||||
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
|
||||
transform: {
|
||||
".ts": ["ts-jest"],
|
||||
".ts": [
|
||||
"ts-jest",
|
||||
{
|
||||
// Note: We shouldn't need to include `isolatedModules` here because it's a deprecated config option in TS 5,
|
||||
// but setting it to `true` fixes the `ESM syntax is not allowed in a CommonJS module when
|
||||
// 'verbatimModuleSyntax' is enabled` error that we're seeing when running our Jest tests.
|
||||
isolatedModules: true,
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
verbose: true,
|
||||
};
|
||||
|
||||
@@ -9,12 +9,5 @@ inputs:
|
||||
service-account-token:
|
||||
description: Your 1Password service account token
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
env:
|
||||
INPUT_CONNECT_HOST: ${{ inputs.connect-host }}
|
||||
INPUT_CONNECT_TOKEN: ${{ inputs.connect-token }}
|
||||
INPUT_SERVICE_ACCOUNT_TOKEN: ${{ inputs.service-account-token }}
|
||||
run: |
|
||||
${{ github.action_path }}/entrypoint.sh
|
||||
using: "node20"
|
||||
main: "dist/index.js"
|
||||
|
||||
27588
configure/dist/index.js
vendored
Normal file
27588
configure/dist/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
configure/dist/package.json
vendored
Normal file
3
configure/dist/package.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/bin/bash
|
||||
# shellcheck disable=SC2086
|
||||
set -e
|
||||
|
||||
# Capture Connect configuration in $GITHUB_ENV, giving (optional) inputs
|
||||
# precendence over OP_CONNECT_* environment variables.
|
||||
|
||||
OP_CONNECT_HOST="${INPUT_CONNECT_HOST:-$OP_CONNECT_HOST}"
|
||||
if [ -n "$OP_CONNECT_HOST" ]; then
|
||||
echo "OP_CONNECT_HOST=$OP_CONNECT_HOST" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
OP_CONNECT_TOKEN="${INPUT_CONNECT_TOKEN:-$OP_CONNECT_TOKEN}"
|
||||
if [ -n "$OP_CONNECT_TOKEN" ]; then
|
||||
echo "OP_CONNECT_TOKEN=$OP_CONNECT_TOKEN" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
OP_SERVICE_ACCOUNT_TOKEN="${INPUT_SERVICE_ACCOUNT_TOKEN:-$OP_SERVICE_ACCOUNT_TOKEN}"
|
||||
if [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
|
||||
echo "OP_SERVICE_ACCOUNT_TOKEN=$OP_SERVICE_ACCOUNT_TOKEN" >> $GITHUB_ENV
|
||||
fi
|
||||
27
configure/index.js
Normal file
27
configure/index.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const core = require("@actions/core");
|
||||
|
||||
const configure = () => {
|
||||
const OP_CONNECT_HOST =
|
||||
core.getInput("connect-host", { required: false }) ||
|
||||
process.env.OP_CONNECT_HOST;
|
||||
const OP_CONNECT_TOKEN =
|
||||
core.getInput("connect-token", { required: false }) ||
|
||||
process.env.OP_CONNECT_TOKEN;
|
||||
const OP_SERVICE_ACCOUNT_TOKEN =
|
||||
core.getInput("service-account-token", { required: false }) ||
|
||||
process.env.OP_SERVICE_ACCOUNT_TOKEN;
|
||||
|
||||
if (OP_CONNECT_HOST) {
|
||||
core.exportVariable("OP_CONNECT_HOST", OP_CONNECT_HOST);
|
||||
}
|
||||
|
||||
if (OP_CONNECT_TOKEN) {
|
||||
core.exportVariable("OP_CONNECT_TOKEN", OP_CONNECT_TOKEN);
|
||||
}
|
||||
|
||||
if (OP_SERVICE_ACCOUNT_TOKEN) {
|
||||
core.exportVariable("OP_SERVICE_ACCOUNT_TOKEN", OP_SERVICE_ACCOUNT_TOKEN);
|
||||
}
|
||||
};
|
||||
|
||||
configure();
|
||||
12560
dist/index.js
vendored
12560
dist/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/package.json
vendored
2
dist/package.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
"type": "commonjs"
|
||||
}
|
||||
|
||||
32
docs/fork-pr-testing.md
Normal file
32
docs/fork-pr-testing.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Fork PR Testing Guide
|
||||
|
||||
This document explains how testing works for external pull requests from forks.
|
||||
|
||||
## Overview
|
||||
|
||||
The testing system consists of two main workflows:
|
||||
|
||||
1. **E2E Tests** (`test-e2e.yml`) - Runs automatically for internal PRs, need manual trigger on external PRs.
|
||||
2. **Ok To Test** (`ok-to-test.yml`) - Dispatches `repository_dispatch` event when maintainer puts the `/ok-to-test sha=<commit hash>` comment in the forked PR thread.
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. PR is created by maintainer:
|
||||
|
||||
For the PR created by maintainer `E2E Test` workflow starts automatically. The PR check will reflect the status of the job.
|
||||
|
||||
### 2. PR is created by external contributor:
|
||||
|
||||
For the PR created by external contributor `E2E Test` workflow **won't** start automatically.
|
||||
Maintainer should make a sanity check of the changes and run it manually by:
|
||||
|
||||
1. Putting a comment `/ok-to-test sha=<latest commit hash>` in the PR thread.
|
||||
2. `E2E Test` workflow starts.
|
||||
3. After `E2E Test` workflow finishes, a comment with a link to the workflow, along with its status will be posted in the PR.
|
||||
4. Maintainer can merge PR or request the changes based on the `E2E Test` results.
|
||||
|
||||
## Notes
|
||||
|
||||
- Only users with **write** permissions can trigger the `/ok-to-test` command.
|
||||
- External PRs are automatically detected and prevented from running e2e tests automatically.
|
||||
- Running e2e test on the external PR is optional. Maintainer can merge PR without running it. Maintainer decides whether it's needed to run an E2E test.
|
||||
46
docs/local-testing.md
Normal file
46
docs/local-testing.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Local Testing Guide
|
||||
|
||||
This document explains how to run e2e tests locally using `act`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Docker** installed and running
|
||||
2. **act** installed ([install guide](https://github.com/nektos/act#installation))
|
||||
```bash
|
||||
brew install act # macOS
|
||||
```
|
||||
3. **1Password credentials** (see [Required Secrets](#required-secrets))
|
||||
4. Build action
|
||||
|
||||
## Required env variables
|
||||
|
||||
| Secret | Description |
|
||||
| -------------------------- | --------------------- |
|
||||
| `OP_SERVICE_ACCOUNT_TOKEN` | Service Account token |
|
||||
| `VAULT` | Vault name or UUID |
|
||||
|
||||
## Building Before Testing
|
||||
|
||||
If you've modified TypeScript code, rebuild before running E2E tests:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Run E2E tests using Service Account
|
||||
|
||||
```bash
|
||||
act push -W .github/workflows/e2e-tests.yml \
|
||||
-s OP_SERVICE_ACCOUNT_TOKEN="$OP_SERVICE_ACCOUNT_TOKEN" \
|
||||
-s VAULT="$VAULT" \
|
||||
-j test-service-account \
|
||||
--matrix os:ubuntu-latest
|
||||
```
|
||||
|
||||
## Run unit tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
172
entrypoint.sh
172
entrypoint.sh
@@ -1,172 +0,0 @@
|
||||
#!/bin/bash
|
||||
# shellcheck disable=SC2046,SC2001,SC2086
|
||||
set -e
|
||||
|
||||
# Pass User-Agent Inforomation to the 1Password CLI
|
||||
export OP_INTEGRATION_NAME="1Password GitHub Action"
|
||||
export OP_INTEGRATION_ID="GHA"
|
||||
export OP_INTEGRATION_BUILDNUMBER="1010001"
|
||||
|
||||
readonly CONNECT="CONNECT"
|
||||
readonly SERVICE_ACCOUNT="SERVICE_ACCOUNT"
|
||||
|
||||
auth_type=$CONNECT
|
||||
managed_variables_var="OP_MANAGED_VARIABLES"
|
||||
IFS=','
|
||||
|
||||
if [[ "$OP_CONNECT_HOST" != "http://"* ]] && [[ "$OP_CONNECT_HOST" != "https://"* ]]; then
|
||||
export OP_CONNECT_HOST="http://"$OP_CONNECT_HOST
|
||||
fi
|
||||
|
||||
# Unset all secrets managed by 1Password if `unset-previous` is set.
|
||||
unset_prev_secrets() {
|
||||
if [ "$INPUT_UNSET_PREVIOUS" == "true" ]; then
|
||||
echo "Unsetting previous values..."
|
||||
|
||||
# Find environment variables that are managed by 1Password.
|
||||
for env_var in "${managed_variables[@]}"; do
|
||||
echo "Unsetting $env_var"
|
||||
unset $env_var
|
||||
|
||||
echo "$env_var=" >> $GITHUB_ENV
|
||||
|
||||
# Keep the masks, just in case.
|
||||
done
|
||||
|
||||
managed_variables=()
|
||||
fi
|
||||
}
|
||||
|
||||
# Install op-cli
|
||||
install_op_cli() {
|
||||
# Create a temporary directory where the CLI is installed
|
||||
OP_INSTALL_DIR="$(mktemp -d)"
|
||||
if [[ ! -d "$OP_INSTALL_DIR" ]]; then
|
||||
echo "Install dir $OP_INSTALL_DIR not found"
|
||||
exit 1
|
||||
fi
|
||||
export OP_INSTALL_DIR
|
||||
echo "::debug::OP_INSTALL_DIR: ${OP_INSTALL_DIR}"
|
||||
|
||||
# Get the latest stable version of the CLI
|
||||
OP_CLI_VERSION="v$(curl https://app-updates.agilebits.com/check/1/0/CLI2/en/2.0.0/N -s | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+')"
|
||||
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
# Get runner's architecture
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$(getconf LONG_BIT)" = 32 ]]; then
|
||||
ARCH="386"
|
||||
elif [[ "$ARCH" == "x86_64" ]]; then
|
||||
ARCH="amd64"
|
||||
elif [[ "$ARCH" == "aarch64" ]]; then
|
||||
ARCH="arm64"
|
||||
fi
|
||||
|
||||
if [[ "$ARCH" != "386" ]] && [[ "$ARCH" != "amd64" ]] && [[ "$ARCH" != "arm" ]] && [[ "$ARCH" != "arm64" ]]; then
|
||||
echo "Unsupported architecture for the 1Password CLI: $ARCH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -sSfLo op.zip "https://cache.agilebits.com/dist/1P/op2/pkg/${OP_CLI_VERSION}/op_linux_${ARCH}_${OP_CLI_VERSION}.zip"
|
||||
unzip -od "$OP_INSTALL_DIR" op.zip && rm op.zip
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
curl -sSfLo op.pkg "https://cache.agilebits.com/dist/1P/op2/pkg/${OP_CLI_VERSION}/op_apple_universal_${OP_CLI_VERSION}.pkg"
|
||||
pkgutil --expand op.pkg temp-pkg
|
||||
tar -xvf temp-pkg/op.pkg/Payload -C "$OP_INSTALL_DIR"
|
||||
rm -rf temp-pkg && rm op.pkg
|
||||
else
|
||||
echo "Operating system not supported yet for this GitHub Action: $OSTYPE."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Uninstall op-cli
|
||||
uninstall_op_cli() {
|
||||
if [[ -d "$OP_INSTALL_DIR" ]]; then
|
||||
rm -fr "$OP_INSTALL_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
populating_secret() {
|
||||
ref=$(printenv $1)
|
||||
|
||||
echo "Populating variable: $1"
|
||||
secret_value=$("${OP_INSTALL_DIR}/op" read "$ref")
|
||||
|
||||
if [ -z "$secret_value" ]; then
|
||||
echo "Could not find or access secret $ref"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Register a mask for the secret to prevent accidental log exposure.
|
||||
# To support multiline secrets, escape percent signs and add a mask per line.
|
||||
escaped_mask_value=$(echo "$secret_value" | sed -e 's/%/%25/g')
|
||||
IFS=$'\n'
|
||||
for line in $escaped_mask_value; do
|
||||
if [ "${#line}" -lt 3 ]; then
|
||||
# To avoid false positives and unreadable logs, omit mask for lines that are too short.
|
||||
continue
|
||||
fi
|
||||
echo "::add-mask::$line"
|
||||
done
|
||||
unset IFS
|
||||
|
||||
# To support multiline secrets, we'll use the heredoc syntax to populate the environment variables.
|
||||
# As the heredoc identifier, we'll use a randomly generated 64-character string,
|
||||
# so that collisions are practically impossible.
|
||||
# Read more: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
|
||||
delimiter="$(openssl rand -hex 32)"
|
||||
|
||||
if [ "$INPUT_EXPORT_ENV" == "true" ]; then
|
||||
{
|
||||
# Populate env var, using heredoc syntax with generated identifier
|
||||
echo "$env_var<<${delimiter}"
|
||||
echo "$secret_value"
|
||||
echo "${delimiter}"
|
||||
} >> $GITHUB_ENV
|
||||
echo "GITHUB_ENV: $(cat $GITHUB_ENV)"
|
||||
|
||||
else
|
||||
{
|
||||
# Populate env var, using heredoc syntax with generated identifier
|
||||
echo "$env_var<<${delimiter}"
|
||||
echo "$secret_value"
|
||||
echo "${delimiter}"
|
||||
} >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
managed_variables+=("$env_var")
|
||||
}
|
||||
|
||||
# Load environment variables using op cli. Iterate over them to find 1Password references, load the secret values,
|
||||
# and make them available as environment variables in the next steps.
|
||||
extract_secrets() {
|
||||
IFS=$'\n'
|
||||
for env_var in $("${OP_INSTALL_DIR}/op" env ls); do
|
||||
populating_secret $env_var
|
||||
done
|
||||
}
|
||||
|
||||
read -r -a managed_variables <<< "$(printenv $managed_variables_var)"
|
||||
|
||||
if [ -z "$OP_CONNECT_TOKEN" ] || [ -z "$OP_CONNECT_HOST" ]; then
|
||||
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
|
||||
echo "(\$OP_CONNECT_TOKEN and \$OP_CONNECT_HOST) or \$OP_SERVICE_ACCOUNT_TOKEN must be set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
auth_type=$SERVICE_ACCOUNT
|
||||
fi
|
||||
|
||||
printf "Authenticated with %s \n" $auth_type
|
||||
|
||||
unset_prev_secrets
|
||||
install_op_cli
|
||||
extract_secrets
|
||||
uninstall_op_cli
|
||||
|
||||
unset IFS
|
||||
# Add extra env var that lists which secrets are managed by 1Password so that in a later step
|
||||
# these can be unset again.
|
||||
managed_variables_str=$(IFS=','; echo "${managed_variables[*]}")
|
||||
echo "$managed_variables_var=$managed_variables_str" >> $GITHUB_ENV
|
||||
6086
package-lock.json
generated
6086
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "load-secrets-action",
|
||||
"version": "1.2.0",
|
||||
"version": "3.0.0",
|
||||
"description": "Load Secrets from 1Password",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"directories": {
|
||||
"test": "tests"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "ncc build ./src/index.ts",
|
||||
"build:configure": "ncc build ./configure/index.js -o ./configure/dist",
|
||||
"build:all": "npm run build && npm run build:configure",
|
||||
"format": "prettier --ignore-path ./config/.prettierignore",
|
||||
"format:check": "npm run format -- --check ./",
|
||||
"format:write": "npm run format -- --write ./",
|
||||
@@ -39,22 +40,26 @@
|
||||
},
|
||||
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
||||
"dependencies": {
|
||||
"@1password/op-js": "^0.1.11",
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/exec": "^1.1.1"
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/tool-cache": "^2.0.2",
|
||||
"dotenv": "^17.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@1password/front-end-style": "^6.0.1",
|
||||
"@types/jest": "^29.5.6",
|
||||
"@types/node": "^18.18.6",
|
||||
"@vercel/ncc": "^0.36.1",
|
||||
"husky": "^8.0.3",
|
||||
"@1password/eslint-config": "^4.3.1",
|
||||
"@1password/prettier-config": "^1.2.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.11.30",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"husky": "^9.0.11",
|
||||
"jest": "^29.7.0",
|
||||
"lint-staged": "^13.3.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"typescript": "^4.9.5"
|
||||
"lint-staged": "^15.2.2",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "./node_modules/@1password/front-end-style/eslintrc.yml",
|
||||
"extends": "@1password/eslint-config",
|
||||
"ignorePatterns": [
|
||||
"coverage/"
|
||||
],
|
||||
@@ -62,5 +67,5 @@
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
},
|
||||
"prettier": "./node_modules/@1password/front-end-style/prettierrc.json"
|
||||
"prettier": "@1password/prettier-config"
|
||||
}
|
||||
|
||||
7
src/constants.ts
Normal file
7
src/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const envConnectHost = "OP_CONNECT_HOST";
|
||||
export const envConnectToken = "OP_CONNECT_TOKEN";
|
||||
export const envServiceAccountToken = "OP_SERVICE_ACCOUNT_TOKEN";
|
||||
export const envManagedVariables = "OP_MANAGED_VARIABLES";
|
||||
export const envFilePath = "OP_ENV_FILE";
|
||||
|
||||
export const authErr = `Authentication error with environment variables: you must set either 1) ${envServiceAccountToken}, or 2) both ${envConnectHost} and ${envConnectToken}.`;
|
||||
54
src/index.ts
54
src/index.ts
@@ -1,20 +1,36 @@
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
import dotenv from "dotenv";
|
||||
import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
import { validateCli } from "@1password/op-js";
|
||||
import { installCliOnGithubActionRunner } from "./op-cli-installer";
|
||||
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
|
||||
import { envFilePath } from "./constants";
|
||||
|
||||
const run = async () => {
|
||||
const loadSecretsAction = async () => {
|
||||
try {
|
||||
const currentFile = url.fileURLToPath(import.meta.url);
|
||||
const currentDir = path.dirname(currentFile);
|
||||
const parentDir = path.resolve(currentDir, "..");
|
||||
|
||||
// Get action inputs
|
||||
process.env.INPUT_UNSET_PREVIOUS = core.getInput("unset-previous");
|
||||
process.env.INPUT_EXPORT_ENV = core.getInput("export-env");
|
||||
const shouldUnsetPrevious = core.getBooleanInput("unset-previous");
|
||||
const shouldExportEnv = core.getBooleanInput("export-env");
|
||||
|
||||
// Execute bash script
|
||||
await exec.exec(`sh -c "` + parentDir + `/entrypoint.sh"`);
|
||||
// Unset all secrets managed by 1Password if `unset-previous` is set.
|
||||
if (shouldUnsetPrevious) {
|
||||
unsetPrevious();
|
||||
}
|
||||
|
||||
// Validate that a proper authentication configuration is set for the CLI
|
||||
validateAuth();
|
||||
|
||||
// Set environment variables from OP_ENV_FILE
|
||||
const file = process.env[envFilePath];
|
||||
if (file) {
|
||||
core.info(`Loading environment variables from file: ${file}`);
|
||||
dotenv.config({ path: file });
|
||||
}
|
||||
|
||||
// Download and install the CLI
|
||||
await installCLI();
|
||||
|
||||
// Load secrets
|
||||
await loadSecrets(shouldExportEnv);
|
||||
} catch (error) {
|
||||
// It's possible for the Error constructor to be modified to be anything
|
||||
// in JavaScript, so the following code accounts for this possibility.
|
||||
@@ -29,4 +45,16 @@ const run = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
// This function's name is an exception from the naming convention
|
||||
// since we refer to the 1Password CLI here.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const installCLI = async (): Promise<void> => {
|
||||
// validateCli checks if there's an existing 1Password CLI installed on the runner.
|
||||
// If there's no CLI installed, then validateCli will throw an error, which we will use
|
||||
// as an indicator that we need to execute the installation script.
|
||||
await validateCli().catch(async () => {
|
||||
await installCliOnGithubActionRunner();
|
||||
});
|
||||
};
|
||||
|
||||
void loadSecretsAction();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import os from "os";
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import * as tc from "@actions/tool-cache";
|
||||
|
||||
export type SupportedPlatform = Extract<
|
||||
NodeJS.Platform,
|
||||
"linux" | "darwin" | "win32"
|
||||
>;
|
||||
|
||||
// maps OS architecture names to 1Password CLI installer architecture names
|
||||
export const archMap: Record<string, string> = {
|
||||
ia32: "386",
|
||||
x64: "amd64",
|
||||
arm: "arm",
|
||||
arm64: "arm64",
|
||||
};
|
||||
|
||||
// Builds the download URL for the 1Password CLI based on the platform and version.
|
||||
export const cliUrlBuilder: Record<
|
||||
SupportedPlatform,
|
||||
(version: string, arch?: string) => string
|
||||
> = {
|
||||
linux: (version, arch) =>
|
||||
`https://cache.agilebits.com/dist/1P/op2/pkg/${version}/op_linux_${arch}_${version}.zip`,
|
||||
darwin: (version) =>
|
||||
`https://cache.agilebits.com/dist/1P/op2/pkg/${version}/op_apple_universal_${version}.pkg`,
|
||||
win32: (version, arch) =>
|
||||
`https://cache.agilebits.com/dist/1P/op2/pkg/${version}/op_windows_${arch}_${version}.zip`,
|
||||
};
|
||||
|
||||
export class CliInstaller {
|
||||
public readonly version: string;
|
||||
public readonly arch: string;
|
||||
|
||||
public constructor(version: string) {
|
||||
this.version = version;
|
||||
this.arch = this.getArch();
|
||||
}
|
||||
|
||||
public async install(url: string): Promise<void> {
|
||||
console.info(`Downloading 1Password CLI from: ${url}`);
|
||||
const downloadPath = await tc.downloadTool(url);
|
||||
console.info("Installing 1Password CLI");
|
||||
const extractedPath = await tc.extractZip(downloadPath);
|
||||
core.addPath(extractedPath);
|
||||
core.info("1Password CLI installed");
|
||||
}
|
||||
|
||||
private getArch(): string {
|
||||
const arch = archMap[os.arch()];
|
||||
if (!arch) {
|
||||
throw new Error("Unsupported architecture");
|
||||
}
|
||||
|
||||
return arch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type Installer, newCliInstaller } from "./installer";
|
||||
@@ -0,0 +1,43 @@
|
||||
import os from "os";
|
||||
|
||||
import { newCliInstaller } from "./installer";
|
||||
import { LinuxInstaller } from "./linux";
|
||||
import { MacOsInstaller } from "./macos";
|
||||
import { WindowsInstaller } from "./windows";
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("newCliInstaller", () => {
|
||||
const version = "1.0.0";
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should return LinuxInstaller for linux platform", () => {
|
||||
jest.spyOn(os, "platform").mockReturnValue("linux");
|
||||
const installer = newCliInstaller(version);
|
||||
expect(installer).toBeInstanceOf(LinuxInstaller);
|
||||
});
|
||||
|
||||
it("should return MacOsInstaller for darwin platform", () => {
|
||||
jest.spyOn(os, "platform").mockReturnValue("darwin");
|
||||
const installer = newCliInstaller(version);
|
||||
expect(installer).toBeInstanceOf(MacOsInstaller);
|
||||
});
|
||||
|
||||
it("should return WindowsInstaller for win32 platform", () => {
|
||||
jest.spyOn(os, "platform").mockReturnValue("win32");
|
||||
const installer = newCliInstaller(version);
|
||||
expect(installer).toBeInstanceOf(WindowsInstaller);
|
||||
});
|
||||
|
||||
it("should throw error for unsupported platform", () => {
|
||||
jest.spyOn(os, "platform").mockReturnValue("sunos");
|
||||
expect(() => newCliInstaller(version)).toThrow(
|
||||
"Unsupported platform: sunos",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import os from "os";
|
||||
|
||||
import { LinuxInstaller } from "./linux";
|
||||
import { MacOsInstaller } from "./macos";
|
||||
import { WindowsInstaller } from "./windows";
|
||||
|
||||
export interface Installer {
|
||||
installCli(): Promise<void>;
|
||||
}
|
||||
|
||||
export const newCliInstaller = (version: string): Installer => {
|
||||
const platform = os.platform();
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
return new LinuxInstaller(version);
|
||||
case "darwin":
|
||||
return new MacOsInstaller(version);
|
||||
case "win32":
|
||||
return new WindowsInstaller(version);
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import os from "os";
|
||||
|
||||
import {
|
||||
archMap,
|
||||
CliInstaller,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
import { LinuxInstaller } from "./linux";
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("LinuxInstaller", () => {
|
||||
const version = "1.2.3";
|
||||
const arch: NodeJS.Architecture = "arm64";
|
||||
|
||||
it("should construct with given version and architecture", () => {
|
||||
jest.spyOn(os, "arch").mockReturnValue(arch);
|
||||
const installer = new LinuxInstaller(version);
|
||||
expect(installer.version).toEqual(version);
|
||||
expect(installer.arch).toEqual(archMap[arch]);
|
||||
});
|
||||
|
||||
it("should call install with correct URL", async () => {
|
||||
const installer = new LinuxInstaller(version);
|
||||
const installMock = jest
|
||||
.spyOn(CliInstaller.prototype, "install")
|
||||
.mockResolvedValue();
|
||||
|
||||
await installer.installCli();
|
||||
|
||||
const builder = cliUrlBuilder["linux" as SupportedPlatform];
|
||||
const url = builder(version, installer.arch);
|
||||
expect(installMock).toHaveBeenCalledWith(url);
|
||||
});
|
||||
});
|
||||
19
src/op-cli-installer/github-action/cli-installer/linux.ts
Normal file
19
src/op-cli-installer/github-action/cli-installer/linux.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
CliInstaller,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
import type { Installer } from "./installer";
|
||||
|
||||
export class LinuxInstaller extends CliInstaller implements Installer {
|
||||
private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux
|
||||
|
||||
public constructor(version: string) {
|
||||
super(version);
|
||||
}
|
||||
|
||||
public async installCli(): Promise<void> {
|
||||
const urlBuilder = cliUrlBuilder[this.platform];
|
||||
await super.install(urlBuilder(this.version, this.arch));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import os from "os";
|
||||
|
||||
import {
|
||||
archMap,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
import { MacOsInstaller } from "./macos";
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("MacOsInstaller", () => {
|
||||
const version = "1.2.3";
|
||||
const arch: NodeJS.Architecture = "x64";
|
||||
|
||||
it("should construct with given version and architecture", () => {
|
||||
jest.spyOn(os, "arch").mockReturnValue(arch);
|
||||
const installer = new MacOsInstaller(version);
|
||||
expect(installer.version).toEqual(version);
|
||||
expect(installer.arch).toEqual(archMap[arch]);
|
||||
});
|
||||
|
||||
it("should call install with correct URL", async () => {
|
||||
const installer = new MacOsInstaller(version);
|
||||
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
|
||||
|
||||
await installer.installCli();
|
||||
|
||||
const builder = cliUrlBuilder["darwin" as SupportedPlatform];
|
||||
const url = builder(version, installer.arch);
|
||||
expect(installMock).toHaveBeenCalledWith(url);
|
||||
});
|
||||
});
|
||||
49
src/op-cli-installer/github-action/cli-installer/macos.ts
Normal file
49
src/op-cli-installer/github-action/cli-installer/macos.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { execFile } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { promisify } from "util";
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import * as tc from "@actions/tool-cache";
|
||||
|
||||
import {
|
||||
CliInstaller,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
import { type Installer } from "./installer";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export class MacOsInstaller extends CliInstaller implements Installer {
|
||||
private readonly platform: SupportedPlatform = "darwin"; // Node.js platform identifier for macOS
|
||||
|
||||
public constructor(version: string) {
|
||||
super(version);
|
||||
}
|
||||
|
||||
public async installCli(): Promise<void> {
|
||||
const urlBuilder = cliUrlBuilder[this.platform];
|
||||
await this.install(urlBuilder(this.version));
|
||||
}
|
||||
|
||||
// @actions/tool-cache package does not support .pkg files, so we need to handle the installation manually
|
||||
public override async install(downloadUrl: string): Promise<void> {
|
||||
console.info(`Downloading 1Password CLI from: ${downloadUrl}`);
|
||||
const pkgPath = await tc.downloadTool(downloadUrl);
|
||||
const pkgWithExtension = `${pkgPath}.pkg`;
|
||||
fs.renameSync(pkgPath, pkgWithExtension);
|
||||
|
||||
const expandDir = "temp-pkg";
|
||||
await execFileAsync("pkgutil", ["--expand", pkgWithExtension, expandDir]);
|
||||
const payloadPath = path.join(expandDir, "op.pkg", "Payload");
|
||||
console.info("Installing 1Password CLI");
|
||||
const cliPath = await tc.extractTar(payloadPath);
|
||||
core.addPath(cliPath);
|
||||
|
||||
fs.rmSync(expandDir, { recursive: true, force: true });
|
||||
fs.rmSync(pkgPath, { force: true });
|
||||
|
||||
core.info("1Password CLI installed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import os from "os";
|
||||
|
||||
import {
|
||||
archMap,
|
||||
CliInstaller,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
import { WindowsInstaller } from "./windows";
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("WindowsInstaller", () => {
|
||||
const version = "1.2.3";
|
||||
const arch: NodeJS.Architecture = "x64";
|
||||
|
||||
it("should construct with given version and architecture", () => {
|
||||
jest.spyOn(os, "arch").mockReturnValue(arch);
|
||||
const installer = new WindowsInstaller(version);
|
||||
expect(installer.version).toEqual(version);
|
||||
expect(installer.arch).toEqual(archMap[arch]);
|
||||
});
|
||||
|
||||
it("should call install with correct URL", async () => {
|
||||
const installer = new WindowsInstaller(version);
|
||||
const installMock = jest
|
||||
.spyOn(CliInstaller.prototype, "install")
|
||||
.mockResolvedValue();
|
||||
|
||||
await installer.installCli();
|
||||
|
||||
const builder = cliUrlBuilder["win32" as SupportedPlatform];
|
||||
const url = builder(version, installer.arch);
|
||||
expect(installMock).toHaveBeenCalledWith(url);
|
||||
});
|
||||
});
|
||||
19
src/op-cli-installer/github-action/cli-installer/windows.ts
Normal file
19
src/op-cli-installer/github-action/cli-installer/windows.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
CliInstaller,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
import type { Installer } from "./installer";
|
||||
|
||||
export class WindowsInstaller extends CliInstaller implements Installer {
|
||||
private readonly platform: SupportedPlatform = "win32"; // Node.js platform identifier for Windows
|
||||
|
||||
public constructor(version: string) {
|
||||
super(version);
|
||||
}
|
||||
|
||||
public async installCli(): Promise<void> {
|
||||
const urlBuilder = cliUrlBuilder[this.platform];
|
||||
await super.install(urlBuilder(this.version, this.arch));
|
||||
}
|
||||
}
|
||||
18
src/op-cli-installer/github-action/index.ts
Normal file
18
src/op-cli-installer/github-action/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { ReleaseChannel, VersionResolver } from "../version";
|
||||
|
||||
import { newCliInstaller } from "./cli-installer";
|
||||
|
||||
// Installs the 1Password CLI on a GitHub Action runner.
|
||||
export const installCliOnGithubActionRunner = async (
|
||||
version?: string,
|
||||
): Promise<void> => {
|
||||
// Get the version from parameter, if not passed - from the job input. Defaults to latest if no version is provided
|
||||
const providedVersion =
|
||||
version || core.getInput("version") || ReleaseChannel.latest;
|
||||
const versionResolver = new VersionResolver(providedVersion);
|
||||
await versionResolver.resolve();
|
||||
const installer = newCliInstaller(versionResolver.get());
|
||||
await installer.installCli();
|
||||
};
|
||||
81
src/op-cli-installer/index.test.ts
Normal file
81
src/op-cli-installer/index.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { newCliInstaller } from "./github-action/cli-installer";
|
||||
import {
|
||||
installCliOnGithubActionRunner,
|
||||
ReleaseChannel,
|
||||
VersionResolver,
|
||||
} from "./index";
|
||||
|
||||
jest.mock("./github-action/cli-installer", () => ({
|
||||
newCliInstaller: jest.fn().mockImplementation((_resolved: string) => ({
|
||||
installCli: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("installCliOnGithubActionRunner", () => {
|
||||
it("should defaults to `latest` when nothing is passed", async () => {
|
||||
jest.spyOn(core, "getInput").mockReturnValue("");
|
||||
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||
jest
|
||||
.spyOn(VersionResolver.prototype, "get")
|
||||
.mockReturnValue(ReleaseChannel.latest);
|
||||
|
||||
await installCliOnGithubActionRunner();
|
||||
|
||||
expect(newCliInstaller).toHaveBeenCalledWith(ReleaseChannel.latest);
|
||||
});
|
||||
|
||||
it("should defaults to `latest` when undefined is passed", async () => {
|
||||
jest.spyOn(core, "getInput").mockReturnValue("");
|
||||
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||
jest
|
||||
.spyOn(VersionResolver.prototype, "get")
|
||||
.mockReturnValue(ReleaseChannel.latest);
|
||||
|
||||
await installCliOnGithubActionRunner(undefined);
|
||||
|
||||
expect(newCliInstaller).toHaveBeenCalledWith(ReleaseChannel.latest);
|
||||
});
|
||||
|
||||
it("should set provided explicit version", async () => {
|
||||
const providedVersion = "1.2.3";
|
||||
jest.spyOn(core, "getInput").mockReturnValue("");
|
||||
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||
jest
|
||||
.spyOn(VersionResolver.prototype, "get")
|
||||
.mockReturnValue(providedVersion);
|
||||
|
||||
await installCliOnGithubActionRunner(providedVersion);
|
||||
|
||||
expect(newCliInstaller).toHaveBeenCalledWith(providedVersion);
|
||||
});
|
||||
|
||||
it("should set version provided as job input", async () => {
|
||||
const providedVersion = "3.0.0";
|
||||
jest.spyOn(core, "getInput").mockReturnValue(providedVersion);
|
||||
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||
jest
|
||||
.spyOn(VersionResolver.prototype, "get")
|
||||
.mockReturnValue(providedVersion);
|
||||
|
||||
await installCliOnGithubActionRunner();
|
||||
|
||||
expect(newCliInstaller).toHaveBeenCalledWith(providedVersion);
|
||||
});
|
||||
|
||||
it("should throw error for invalid version", async () => {
|
||||
const providedVersion = "invalid";
|
||||
jest.spyOn(core, "getInput").mockReturnValue(providedVersion);
|
||||
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||
jest
|
||||
.spyOn(VersionResolver.prototype, "get")
|
||||
.mockReturnValue(providedVersion);
|
||||
|
||||
await expect(installCliOnGithubActionRunner()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
2
src/op-cli-installer/index.ts
Normal file
2
src/op-cli-installer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { installCliOnGithubActionRunner } from "./github-action";
|
||||
export { ReleaseChannel, VersionResolver } from "./version";
|
||||
13
src/op-cli-installer/version/constants.ts
Normal file
13
src/op-cli-installer/version/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export enum ReleaseChannel {
|
||||
latest = "latest",
|
||||
latestBeta = "latest-beta",
|
||||
}
|
||||
|
||||
export interface VersionResponse {
|
||||
// eslint disabled next line as CLI2 is expected in getting CLI versions response
|
||||
/* eslint-disable-next-line @typescript-eslint/naming-convention */
|
||||
CLI2: {
|
||||
release: { version: string };
|
||||
beta: { version: string };
|
||||
};
|
||||
}
|
||||
91
src/op-cli-installer/version/helper.test.ts
Normal file
91
src/op-cli-installer/version/helper.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { ReleaseChannel } from "./constants";
|
||||
import { getLatestVersion } from "./helper";
|
||||
|
||||
describe("getLatestVersion", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return latest stable version", async () => {
|
||||
const mockResponse = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CLI2: {
|
||||
release: { version: "2.31.0" },
|
||||
beta: { version: "2.32.0-beta.01" },
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const version = await getLatestVersion(ReleaseChannel.latest);
|
||||
expect(version).toBe("2.31.0");
|
||||
});
|
||||
|
||||
it("should return latest beta version", async () => {
|
||||
const mockResponse = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CLI2: {
|
||||
release: { version: "2.31.0" },
|
||||
beta: { version: "2.32.0-beta.01" },
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const version = await getLatestVersion(ReleaseChannel.latestBeta);
|
||||
expect(version).toBe("2.32.0-beta.01");
|
||||
});
|
||||
|
||||
it("should throw if no CLI2 field", async () => {
|
||||
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
await expect(getLatestVersion(ReleaseChannel.latest)).rejects.toThrow(
|
||||
`No ${ReleaseChannel.latest} versions found`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if no stable version found", async () => {
|
||||
const mockResponse = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CLI2: {
|
||||
beta: { version: "2.32.0-beta.01" },
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
await expect(getLatestVersion(ReleaseChannel.latest)).rejects.toThrow(
|
||||
`No ${ReleaseChannel.latest} versions found`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if no beta version found", async () => {
|
||||
const mockResponse = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CLI2: {
|
||||
release: { version: "2.32.0" },
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
await expect(getLatestVersion(ReleaseChannel.latestBeta)).rejects.toThrow(
|
||||
`No ${ReleaseChannel.latestBeta} versions found`,
|
||||
);
|
||||
});
|
||||
});
|
||||
23
src/op-cli-installer/version/helper.ts
Normal file
23
src/op-cli-installer/version/helper.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { ReleaseChannel, type VersionResponse } from "./constants";
|
||||
|
||||
// Returns the latest version of the 1Password CLI based on the specified channel.
|
||||
export const getLatestVersion = async (
|
||||
channel: ReleaseChannel,
|
||||
): Promise<string> => {
|
||||
core.info(`Getting ${channel} version number`);
|
||||
const res = await fetch("https://app-updates.agilebits.com/latest");
|
||||
const json = (await res.json()) as VersionResponse;
|
||||
const latestStable = json?.CLI2?.release?.version;
|
||||
const latestBeta = json?.CLI2?.beta?.version;
|
||||
const version =
|
||||
channel === ReleaseChannel.latestBeta ? latestBeta : latestStable;
|
||||
|
||||
if (!version) {
|
||||
core.error(`No ${channel} versions found`);
|
||||
throw new Error(`No ${channel} versions found`);
|
||||
}
|
||||
|
||||
return version;
|
||||
};
|
||||
2
src/op-cli-installer/version/index.ts
Normal file
2
src/op-cli-installer/version/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { VersionResolver } from "./version-resolver";
|
||||
export { ReleaseChannel } from "./constants";
|
||||
45
src/op-cli-installer/version/validate.test.ts
Normal file
45
src/op-cli-installer/version/validate.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
|
||||
import { validateVersion } from "./validate";
|
||||
|
||||
describe("validateVersion", () => {
|
||||
it('should not throw for "latest"', () => {
|
||||
expect(() => validateVersion("latest")).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for "latest-beta"', () => {
|
||||
expect(() => validateVersion("latest-beta")).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for valid semver version "2.18.0"', () => {
|
||||
expect(() => validateVersion("2.18.0")).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for partial version "2"', () => {
|
||||
expect(() => validateVersion("2")).toThrow();
|
||||
});
|
||||
|
||||
it('should throw for partial version "2.1"', () => {
|
||||
expect(() => validateVersion("2.1")).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for valid beta "2.19.0-beta.01"', () => {
|
||||
expect(() => validateVersion("2.19.0-beta.01")).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for valid beta "2.19.3-beta.12"', () => {
|
||||
expect(() => validateVersion("2.19.3-beta.12")).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for coerced version "v2.19.0"', () => {
|
||||
expect(() => validateVersion("v2.19.0")).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for invalid version "latest-abc"', () => {
|
||||
expect(() => validateVersion("latest-abc")).toThrow();
|
||||
});
|
||||
|
||||
it("should throw for empty string", () => {
|
||||
expect(() => validateVersion("")).toThrow();
|
||||
});
|
||||
});
|
||||
23
src/op-cli-installer/version/validate.ts
Normal file
23
src/op-cli-installer/version/validate.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import semver from "semver";
|
||||
|
||||
import { ReleaseChannel } from "./constants";
|
||||
|
||||
// Validates if the provided version type is a valid enum value or a valid semver version.
|
||||
export const validateVersion = (input: string): void => {
|
||||
if (Object.values(ReleaseChannel).includes(input as ReleaseChannel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1Password beta releases (aka 2.19.0-beta.01) are not semver compliant.
|
||||
// According to semver, it should be "2.19.0-beta.1".
|
||||
// That's why we need to normalize them before validating.
|
||||
// Accepts valid semver versions like "2.18.0" or beta-releases like "2.19.0-beta.01"
|
||||
// or versions with 'v' prefix like "v2.19.0"
|
||||
const normalized = input.replace(/-beta\.0*(\d+)/, "-beta.$1");
|
||||
const normInput = new semver.SemVer(normalized);
|
||||
if (semver.valid(normInput)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid version input: ${input}`);
|
||||
};
|
||||
58
src/op-cli-installer/version/version-resolver.test.ts
Normal file
58
src/op-cli-installer/version/version-resolver.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect } from "@jest/globals";
|
||||
|
||||
import { ReleaseChannel } from "./constants";
|
||||
import { VersionResolver } from "./version-resolver";
|
||||
|
||||
describe("VersionResolver", () => {
|
||||
test("should throw error when invalid version provided", () => {
|
||||
expect(() => new VersionResolver("vv")).toThrow();
|
||||
});
|
||||
|
||||
test("should throw error when version is empty", () => {
|
||||
expect(() => new VersionResolver("")).toThrow();
|
||||
});
|
||||
|
||||
test("should throw error for major version only", () => {
|
||||
expect(() => new VersionResolver("1")).toThrow();
|
||||
});
|
||||
|
||||
test("should throw error for major and minor version only", () => {
|
||||
expect(() => new VersionResolver("1.0")).toThrow();
|
||||
});
|
||||
|
||||
test("should resolve latest stable version", async () => {
|
||||
const versionResolver = new VersionResolver(ReleaseChannel.latest);
|
||||
await versionResolver.resolve();
|
||||
expect(versionResolver.get()).toBeDefined();
|
||||
});
|
||||
|
||||
test("should resolve latest beta version", async () => {
|
||||
const versionResolver = new VersionResolver(ReleaseChannel.latestBeta);
|
||||
await versionResolver.resolve();
|
||||
expect(versionResolver.get()).toBeDefined();
|
||||
});
|
||||
|
||||
test("should resolve version without 'v' prefix", async () => {
|
||||
const versionResolver = new VersionResolver("1.0.0");
|
||||
await versionResolver.resolve();
|
||||
expect(versionResolver.get()).toBe("v1.0.0");
|
||||
});
|
||||
|
||||
test("should resolve version with 'v' prefix", async () => {
|
||||
const versionResolver = new VersionResolver("v1.0.0");
|
||||
await versionResolver.resolve();
|
||||
expect(versionResolver.get()).toBe("v1.0.0");
|
||||
});
|
||||
|
||||
test("should resolve beta version without 'v' prefix", async () => {
|
||||
const versionResolver = new VersionResolver("2.19.0-beta.01");
|
||||
await versionResolver.resolve();
|
||||
expect(versionResolver.get()).toBe("v2.19.0-beta.01");
|
||||
});
|
||||
|
||||
test("should resolve beta version with 'v' prefix", async () => {
|
||||
const versionResolver = new VersionResolver("v2.19.0-beta.01");
|
||||
await versionResolver.resolve();
|
||||
expect(versionResolver.get()).toBe("v2.19.0-beta.01");
|
||||
});
|
||||
});
|
||||
45
src/op-cli-installer/version/version-resolver.ts
Normal file
45
src/op-cli-installer/version/version-resolver.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { ReleaseChannel } from "./constants";
|
||||
import { getLatestVersion } from "./helper";
|
||||
import { validateVersion } from "./validate";
|
||||
|
||||
export class VersionResolver {
|
||||
private version: string;
|
||||
|
||||
public constructor(version: string) {
|
||||
this.validate(version);
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public get(): string {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
public async resolve(): Promise<void> {
|
||||
core.info(`Resolving version: ${this.version}`);
|
||||
if (!this.version) {
|
||||
core.error("Version is not provided");
|
||||
throw new Error("Version is not provided");
|
||||
}
|
||||
|
||||
if (this.isReleaseChannel(this.version)) {
|
||||
this.version = await getLatestVersion(this.version);
|
||||
}
|
||||
|
||||
// add `v` prefix if not already present
|
||||
this.version = this.version.startsWith("v")
|
||||
? this.version
|
||||
: `v${this.version}`;
|
||||
}
|
||||
|
||||
private validate(version: string) {
|
||||
core.info(`Validating version number: '${version}'`);
|
||||
validateVersion(version);
|
||||
core.info(`Version number '${version}' is valid`);
|
||||
}
|
||||
|
||||
private isReleaseChannel(value: string): value is ReleaseChannel {
|
||||
return Object.values(ReleaseChannel).includes(value as ReleaseChannel);
|
||||
}
|
||||
}
|
||||
164
src/utils.test.ts
Normal file
164
src/utils.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
import { read, setClientInfo } from "@1password/op-js";
|
||||
import {
|
||||
extractSecret,
|
||||
loadSecrets,
|
||||
unsetPrevious,
|
||||
validateAuth,
|
||||
} from "./utils";
|
||||
import {
|
||||
authErr,
|
||||
envConnectHost,
|
||||
envConnectToken,
|
||||
envManagedVariables,
|
||||
envServiceAccountToken,
|
||||
} from "./constants";
|
||||
|
||||
jest.mock("@actions/core");
|
||||
jest.mock("@actions/exec", () => ({
|
||||
getExecOutput: jest.fn(() => ({
|
||||
stdout: "MOCK_SECRET",
|
||||
})),
|
||||
}));
|
||||
jest.mock("@1password/op-js");
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("validateAuth", () => {
|
||||
const testConnectHost = "https://localhost:8000";
|
||||
const testConnectToken = "token";
|
||||
const testServiceAccountToken = "ops_token";
|
||||
|
||||
beforeEach(() => {
|
||||
process.env[envConnectHost] = "";
|
||||
process.env[envConnectToken] = "";
|
||||
process.env[envServiceAccountToken] = "";
|
||||
});
|
||||
|
||||
it("should throw an error when no config is provided", () => {
|
||||
expect(validateAuth).toThrow(authErr);
|
||||
});
|
||||
|
||||
it("should throw an error when partial Connect config is provided", () => {
|
||||
process.env[envConnectHost] = testConnectHost;
|
||||
expect(validateAuth).toThrow(authErr);
|
||||
});
|
||||
|
||||
it("should be authenticated as a Connect client", () => {
|
||||
process.env[envConnectHost] = testConnectHost;
|
||||
process.env[envConnectToken] = testConnectToken;
|
||||
expect(validateAuth).not.toThrow(authErr);
|
||||
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
|
||||
});
|
||||
|
||||
it("should be authenticated as a service account", () => {
|
||||
process.env[envServiceAccountToken] = testServiceAccountToken;
|
||||
expect(validateAuth).not.toThrow(authErr);
|
||||
expect(core.info).toHaveBeenCalledWith(
|
||||
"Authenticated with Service account.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should prioritize Connect over service account if both are configured", () => {
|
||||
process.env[envServiceAccountToken] = testServiceAccountToken;
|
||||
process.env[envConnectHost] = testConnectHost;
|
||||
process.env[envConnectToken] = testConnectToken;
|
||||
expect(validateAuth).not.toThrow(authErr);
|
||||
expect(core.warning).toHaveBeenCalled();
|
||||
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSecret", () => {
|
||||
const envTestSecretEnv = "TEST_SECRET";
|
||||
const testSecretRef = "op://vault/item/secret";
|
||||
const testSecretValue = "Secret1@3$";
|
||||
|
||||
read.parse = jest.fn().mockReturnValue(testSecretValue);
|
||||
|
||||
process.env[envTestSecretEnv] = testSecretRef;
|
||||
|
||||
it("should set secret as step output", () => {
|
||||
extractSecret(envTestSecretEnv, false);
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith(
|
||||
envTestSecretEnv,
|
||||
testSecretValue,
|
||||
);
|
||||
expect(core.setOutput).toHaveBeenCalledWith(
|
||||
envTestSecretEnv,
|
||||
testSecretValue,
|
||||
);
|
||||
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
||||
});
|
||||
|
||||
it("should set secret as environment variable", () => {
|
||||
extractSecret(envTestSecretEnv, true);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||
envTestSecretEnv,
|
||||
testSecretValue,
|
||||
);
|
||||
expect(core.setOutput).not.toHaveBeenCalledWith(
|
||||
envTestSecretEnv,
|
||||
testSecretValue,
|
||||
);
|
||||
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSecrets", () => {
|
||||
it("sets the client info and gets the executed output", async () => {
|
||||
await loadSecrets(true);
|
||||
|
||||
expect(setClientInfo).toHaveBeenCalledWith({
|
||||
name: "1Password GitHub Action",
|
||||
id: "GHA",
|
||||
});
|
||||
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||
"OP_MANAGED_VARIABLES",
|
||||
"MOCK_SECRET",
|
||||
);
|
||||
});
|
||||
|
||||
it("return early if no env vars with secrets found", async () => {
|
||||
(exec.getExecOutput as jest.Mock).mockReturnValueOnce({ stdout: "" });
|
||||
await loadSecrets(true);
|
||||
|
||||
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
|
||||
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("core.exportVariable", () => {
|
||||
it("is called when shouldExportEnv is true", async () => {
|
||||
await loadSecrets(true);
|
||||
|
||||
expect(core.exportVariable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is not called when shouldExportEnv is false", async () => {
|
||||
await loadSecrets(false);
|
||||
|
||||
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsetPrevious", () => {
|
||||
const testManagedEnv = "TEST_SECRET";
|
||||
const testSecretValue = "MyS3cr#T";
|
||||
|
||||
beforeEach(() => {
|
||||
process.env[testManagedEnv] = testSecretValue;
|
||||
process.env[envManagedVariables] = testManagedEnv;
|
||||
});
|
||||
|
||||
it("should unset the environment variable if user wants it", () => {
|
||||
unsetPrevious();
|
||||
expect(core.info).toHaveBeenCalledWith("Unsetting previous values ...");
|
||||
expect(core.info).toHaveBeenCalledWith("Unsetting TEST_SECRET");
|
||||
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
|
||||
});
|
||||
});
|
||||
91
src/utils.ts
Normal file
91
src/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
import { read, setClientInfo, semverToInt } from "@1password/op-js";
|
||||
import { version } from "../package.json";
|
||||
import {
|
||||
authErr,
|
||||
envConnectHost,
|
||||
envConnectToken,
|
||||
envServiceAccountToken,
|
||||
envManagedVariables,
|
||||
} from "./constants";
|
||||
|
||||
export const validateAuth = (): void => {
|
||||
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
|
||||
const isServiceAccount = process.env[envServiceAccountToken];
|
||||
|
||||
if (isConnect && isServiceAccount) {
|
||||
core.warning(
|
||||
"WARNING: Both service account and Connect credentials are provided. Connect credentials will take priority.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!isConnect && !isServiceAccount) {
|
||||
throw new Error(authErr);
|
||||
}
|
||||
|
||||
const authType = isConnect ? "Connect" : "Service account";
|
||||
|
||||
core.info(`Authenticated with ${authType}.`);
|
||||
};
|
||||
|
||||
export const extractSecret = (
|
||||
envName: string,
|
||||
shouldExportEnv: boolean,
|
||||
): void => {
|
||||
core.info(`Populating variable: ${envName}`);
|
||||
|
||||
const ref = process.env[envName];
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretValue = read.parse(ref);
|
||||
if (!secretValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldExportEnv) {
|
||||
core.exportVariable(envName, secretValue);
|
||||
} else {
|
||||
core.setOutput(envName, secretValue);
|
||||
}
|
||||
core.setSecret(secretValue);
|
||||
};
|
||||
|
||||
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
||||
// Pass User-Agent Information to the 1Password CLI
|
||||
setClientInfo({
|
||||
name: "1Password GitHub Action",
|
||||
id: "GHA",
|
||||
build: semverToInt(version),
|
||||
});
|
||||
|
||||
// Load secrets from environment variables using 1Password CLI.
|
||||
// Iterate over them to find 1Password references, extract the secret values,
|
||||
// and make them available in the next steps either as step outputs or as environment variables.
|
||||
const res = await exec.getExecOutput(`sh -c "op env ls"`);
|
||||
|
||||
if (res.stdout === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const envs = res.stdout.replace(/\n+$/g, "").split(/\r?\n/);
|
||||
for (const envName of envs) {
|
||||
extractSecret(envName, shouldExportEnv);
|
||||
}
|
||||
if (shouldExportEnv) {
|
||||
core.exportVariable(envManagedVariables, envs.join());
|
||||
}
|
||||
};
|
||||
|
||||
export const unsetPrevious = (): void => {
|
||||
if (process.env[envManagedVariables]) {
|
||||
core.info("Unsetting previous values ...");
|
||||
const managedEnvs = process.env[envManagedVariables].split(",");
|
||||
for (const envName of managedEnvs) {
|
||||
core.info(`Unsetting ${envName}`);
|
||||
core.exportVariable(envName, "");
|
||||
}
|
||||
}
|
||||
};
|
||||
3
tests/.env.tpl
Normal file
3
tests/.env.tpl
Normal file
@@ -0,0 +1,3 @@
|
||||
FILE_SECRET=op://acceptance-tests/test-secret/password
|
||||
FILE_SECRET_IN_SECTION=op://acceptance-tests/test-secret/test-section/password
|
||||
FILE_MULTILINE_SECRET=op://acceptance-tests/multiline-secret/notesPlain
|
||||
@@ -9,11 +9,8 @@ assert_env_equals() {
|
||||
fi
|
||||
}
|
||||
|
||||
assert_env_equals "SECRET" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
|
||||
|
||||
assert_env_equals "SECRET_IN_SECTION" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
|
||||
|
||||
assert_env_equals "MULTILINE_SECRET" "$(cat << EOF
|
||||
readonly SECRET="RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
|
||||
MULTILINE_SECRET="$(cat << EOF
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLApXaGls
|
||||
ZSB3ZSBkZWVwbHkgYXBwcmVjaWF0ZSB5b3VyIHZp
|
||||
@@ -28,3 +25,13 @@ IApTbyBwbGVhc2UgZG9uJ3QgcmVwb3J0IGl0IQo=
|
||||
-----END PRIVATE KEY-----
|
||||
EOF
|
||||
)"
|
||||
readonly MULTILINE_SECRET
|
||||
|
||||
assert_env_equals "SECRET" "${SECRET}"
|
||||
assert_env_equals "FILE_SECRET" "${SECRET}"
|
||||
|
||||
assert_env_equals "SECRET_IN_SECTION" "${SECRET}"
|
||||
assert_env_equals "FILE_SECRET_IN_SECTION" "${SECRET}"
|
||||
|
||||
assert_env_equals "MULTILINE_SECRET" "${MULTILINE_SECRET}"
|
||||
assert_env_equals "FILE_MULTILINE_SECRET" "${MULTILINE_SECRET}"
|
||||
@@ -10,5 +10,10 @@ assert_env_unset() {
|
||||
}
|
||||
|
||||
assert_env_unset "SECRET"
|
||||
assert_env_unset "FILE_SECRET"
|
||||
|
||||
assert_env_unset "SECRET_IN_SECTION"
|
||||
assert_env_unset "FILE_SECRET_IN_SECTION"
|
||||
|
||||
assert_env_unset "MULTILINE_SECRET"
|
||||
assert_env_unset "FILE_MULTILINE_SECRET"
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
"esModuleInterop": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"importsNotUsedAsValues": "error",
|
||||
"isolatedModules": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
@@ -17,9 +15,9 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"outDir": "./dist/",
|
||||
"rootDir": "./src/",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"target": "es2022"
|
||||
"target": "es2022",
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user