Compare commits
141 Commits
eddy/cli-c
...
feature/mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2af23419 | ||
|
|
652d567877 | ||
|
|
ee92e1fd32 | ||
|
|
d688c27248 | ||
|
|
a312828d43 | ||
|
|
e6b45e828c | ||
|
|
639ddd6614 | ||
|
|
e5d7353d74 | ||
|
|
a665f2c1ab | ||
|
|
398c918d60 | ||
|
|
485265b41c | ||
|
|
dc90451a94 | ||
|
|
9d7acefac9 | ||
|
|
04984a6c91 | ||
|
|
db7314de7b | ||
|
|
3f9ba481c9 | ||
|
|
1e8273d4be | ||
|
|
015b03300e | ||
|
|
ab44f9f69c | ||
|
|
af49dd18de | ||
|
|
d456b72513 | ||
|
|
2a828228a8 | ||
|
|
604a86ce4e | ||
|
|
7998453500 | ||
|
|
e7fe4397d9 | ||
|
|
6911316fe3 | ||
|
|
24235f3b6b | ||
|
|
a2ce22dd39 | ||
|
|
d2fdd9df66 | ||
|
|
95478552e8 | ||
|
|
4a997a0402 | ||
|
|
81bc2a50b4 | ||
|
|
1dfe1fc19e | ||
|
|
856971e6d6 | ||
|
|
5fd6fbcfdf | ||
|
|
13f927c806 | ||
|
|
fdb192f5dc | ||
|
|
13c259d353 | ||
|
|
b91fef0861 | ||
|
|
2d74546fd1 | ||
|
|
8d0d610af1 | ||
|
|
76bec67e89 | ||
|
|
74311b1273 | ||
|
|
5999940e48 | ||
|
|
b43a2248cc | ||
|
|
c2b96b53cd | ||
|
|
6f52eddca2 | ||
|
|
dc5cd4d17f | ||
|
|
b4962e1861 | ||
|
|
2f243ca4fa | ||
|
|
c96389a7ae | ||
|
|
ba38da7905 | ||
|
|
6961848b51 | ||
|
|
fac78884c8 | ||
|
|
2c0496a719 | ||
|
|
483f83267a | ||
|
|
0ee5bc7530 | ||
|
|
fee9db6b39 | ||
|
|
1824b2f006 | ||
|
|
0f3110274c | ||
|
|
80f581e4b5 | ||
|
|
74df766d96 | ||
|
|
df80909445 | ||
|
|
e3aa72700f | ||
|
|
6a721fb6aa | ||
|
|
7ee331322f | ||
|
|
9fb38d43e1 | ||
|
|
bdf1f8ceff | ||
|
|
7c3deef5f9 | ||
|
|
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 | ||
|
|
b575844081 | ||
|
|
c7a1c1e3bb | ||
|
|
a2a357a196 | ||
|
|
d1a4e73495 | ||
|
|
83858b7236 | ||
|
|
10ed0757b7 | ||
|
|
539eaa66ee | ||
|
|
08315da4b3 | ||
|
|
0e91b4a315 | ||
|
|
9c2d98ed07 | ||
|
|
d8ac5d7286 | ||
|
|
15d95ae871 | ||
|
|
a48d1fcd00 |
205
.github/workflows/e2e-tests.yml
vendored
Normal file
205
.github/workflows/e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,205 @@
|
||||
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:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref to checkout"
|
||||
required: true
|
||||
type: string
|
||||
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 }}, export-env=${{ matrix.export-env }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
export-env: [true, false]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build actions
|
||||
run: npm run build:all
|
||||
|
||||
- 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:
|
||||
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
|
||||
|
||||
- name: Load secrets (invalid ref - expect failure)
|
||||
id: load_invalid
|
||||
continue-on-error: true
|
||||
uses: ./
|
||||
env:
|
||||
BAD_REF: "op://x"
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
with:
|
||||
export-env: true
|
||||
|
||||
- name: Assert invalid ref failed
|
||||
shell: bash
|
||||
run: ./tests/assert-invalid-ref-failed.sh
|
||||
env:
|
||||
STEP_OUTCOME: ${{ steps.load_invalid.outcome }}
|
||||
|
||||
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
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build actions
|
||||
run: npm run build:all
|
||||
|
||||
- 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
|
||||
13
.github/workflows/lint.yml
vendored
13
.github/workflows/lint.yml
vendored
@@ -1,13 +0,0 @@
|
||||
on: pull_request
|
||||
name: Lint
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/action-shellcheck@2.0.0
|
||||
with:
|
||||
ignore_paths: >-
|
||||
.husky
|
||||
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
|
||||
120
.github/workflows/test-e2e.yml
vendored
Normal file
120
.github/workflows/test-e2e.yml
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
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 }}
|
||||
ref: ${{ steps.check.outputs.ref }}
|
||||
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)"
|
||||
echo "ref=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
|
||||
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)"
|
||||
echo "ref=$PR_HEAD_SHA" >> $GITHUB_OUTPUT
|
||||
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)"
|
||||
echo "ref=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
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
|
||||
with:
|
||||
ref: ${{ needs.check-external-pr.outputs.ref }}
|
||||
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 }}
|
||||
136
.github/workflows/test.yml
vendored
136
.github/workflows/test.yml
vendored
@@ -1,136 +0,0 @@
|
||||
on: push
|
||||
name: Run acceptance tests
|
||||
|
||||
jobs:
|
||||
test-with-output-secrets:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ ubuntu-latest, macos-latest ]
|
||||
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
|
||||
test-with-export-env:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ ubuntu-latest, macos-latest ]
|
||||
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
|
||||
test-references-with-ids:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ ubuntu-latest, macos-latest ]
|
||||
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
|
||||
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)
|
||||
455
README.md
455
README.md
@@ -1,38 +1,29 @@
|
||||
# Load Secrets from 1Password - GitHub Action
|
||||
<!-- Image sourced from https://blog.1password.com/1password-service-accounts/ -->
|
||||
<img alt="" role="img" src="https://blog.1password.com/posts/2023/1password-service-accounts/header.png"/>
|
||||
|
||||
This action loads secrets from 1Password into GitHub Actions using [1Password Connect](https://developer.1password.com/docs/connect) or a [Service Account <sup>[BETA]</sup>](https://developer.1password.com/docs/service-accounts).
|
||||
<div align="center">
|
||||
<h1>Load Secrets from 1Password - GitHub Action</h1>
|
||||
<p>Provide the secrets your GitHub runner needs from 1Password.</p>
|
||||
<a href="https://developer.1password.com/docs/ci-cd/github-actions">
|
||||
<img alt="Get started" src="https://user-images.githubusercontent.com/45081667/226940040-16d3684b-60f4-4d95-adb2-5757a8f1bc15.png" height="37"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
`load-secrets-action` loads secrets from 1Password into GitHub Actions using [Service Accounts](https://developer.1password.com/docs/service-accounts) or [1Password Connect](https://developer.1password.com/docs/connect).
|
||||
|
||||
Specify in your workflow YAML file which secrets from 1Password should be loaded into your job, and the action will make them available as environment variables for the next steps.
|
||||
|
||||
Read more on the [1Password Developer Portal](https://developer.1password.com/docs/ci-cd/github-actions).
|
||||
|
||||
## Requirements
|
||||
## 🪄 See it in action!
|
||||
|
||||
Before you get started, you'll need to:
|
||||
[](https://www.youtube.com/watch?v=kVBl5iQYgSA "Using 1Password Service Accounts with GitHub Actions")
|
||||
|
||||
- [Deploy 1Password Connect](/docs/connect/get-started#step-2-deploy-1password-connect-server) in your infrastructure.
|
||||
- Set the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` environment variables to your Connect instance's credentials, so it'll be used to load secrets.
|
||||
## ✨ Quickstart
|
||||
|
||||
_Supported runners_: You can run the action on Mac and Linux runners. Windows is currently not supported.
|
||||
|
||||
## Usage
|
||||
|
||||
You can configure the action to use your 1Password Connect instance.
|
||||
|
||||
If you provide `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` variables, the Connect instance will be used to load secrets. Make sure [1Password Connect](https://support.1password.com/secrets-automation/#step-2-deploy-a-1password-connect-server) is deployed in your infrastructure.
|
||||
|
||||
If you provide `OP_SERVICE_ACCOUNT_TOKEN` variable, the service account will be used to load secrets.
|
||||
|
||||
**_Note_**: If all environment variables have been set, the Connect credentials will take precedence over the provided service account token. You must unset the Connect environment variables to ensure the action uses the service account token.
|
||||
|
||||
There are two ways that secrets can be loaded:
|
||||
|
||||
- [use the secrets from the action's ouput](#use-secrets-from-the-actions-output)
|
||||
- [export secrets as environment variables](#export-secrets-as-environment-variables)
|
||||
|
||||
### Use secrets from the action's output
|
||||
|
||||
This method allows for you to use the loaded secrets as an output from the step: `steps.step-id.outputs.secret-name`. You will need to set an id for the step that uses this action to be able to access its outputs. For more details, , see [`outputs.<output_id>`](https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputsoutput_id).
|
||||
### Export secrets as a step's output (recommended)
|
||||
|
||||
```yml
|
||||
on: push
|
||||
@@ -40,143 +31,22 @@ jobs:
|
||||
hello-world:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Load secret
|
||||
id: op-load-secret
|
||||
uses: 1password/load-secrets-action@v1
|
||||
with:
|
||||
export-env: false
|
||||
env:
|
||||
OP_CONNECT_HOST: <Your Connect instance URL>
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
SECRET: op://app-cicd/hello-world/secret
|
||||
|
||||
- name: Print masked secret
|
||||
run: echo "Secret: ${{ steps.op-load-secret.outputs.SECRET }}"
|
||||
# Prints: Secret: ***
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Usage example with Service Accounts <sup>BETA</sup></b></summary>
|
||||
|
||||
```yml
|
||||
on: push
|
||||
jobs:
|
||||
hello-world:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Load secret
|
||||
id: op-load-secret
|
||||
uses: 1password/load-secrets-action@v1
|
||||
with:
|
||||
export-env: false
|
||||
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.op-load-secret.outputs.SECRET }}"
|
||||
run: 'echo "Secret: ${{ steps.load_secrets.outputs.SECRET }}"'
|
||||
# Prints: Secret: ***
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Longer usage example</b></summary>
|
||||
|
||||
```yml
|
||||
on: push
|
||||
name: Deploy app
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Configure 1Password Connect
|
||||
uses: 1password/load-secrets-action/configure@v1
|
||||
with:
|
||||
# Persist the 1Password Connect URL for next steps. You can also persist
|
||||
# the Connect token using input `connect-token`, but keep in mind that
|
||||
# this will grant all steps of the job access to the token.
|
||||
connect-host: https://1password.acme.com
|
||||
|
||||
- name: Load Docker credentials
|
||||
id: load-docker-credentials
|
||||
uses: 1password/load-secrets-action@v1
|
||||
with:
|
||||
export-env: false
|
||||
env:
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
DOCKERHUB_USERNAME: op://app-cicd/docker/username
|
||||
DOCKERHUB_TOKEN: op://app-cicd/docker/token
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_USERNAME }}
|
||||
password: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
tags: acme/app:latest
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Longer usage example with Service Accounts <sup>BETA</sup></b></summary>
|
||||
|
||||
```yml
|
||||
on: push
|
||||
name: Deploy app
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Configure 1Password Connect
|
||||
uses: 1password/load-secrets-action/configure@v1
|
||||
with:
|
||||
# Persist the 1Password Service Account token. This will grant
|
||||
# all steps of the job access to the token.
|
||||
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
|
||||
- name: Load Docker credentials
|
||||
id: load-docker-credentials
|
||||
uses: 1password/load-secrets-action@v1
|
||||
with:
|
||||
export-env: false
|
||||
env:
|
||||
DOCKERHUB_USERNAME: op://app-cicd/docker/username
|
||||
DOCKERHUB_TOKEN: op://app-cicd/docker/token
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_USERNAME }}
|
||||
password: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
tags: acme/app:latest
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Export secrets as environment variables
|
||||
|
||||
This method, allows the action to access the loaded secrets as environment variables. These environment variables are accessible at a job level.
|
||||
### Export secrets as env variables
|
||||
|
||||
```yml
|
||||
on: push
|
||||
@@ -184,285 +54,46 @@ 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
|
||||
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"
|
||||
run: 'echo "Secret: $SECRET"'
|
||||
# Prints: Secret: ***
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Usage example with Service Accounts <sup>BETA</sup></b></summary>
|
||||
### 🔑 SSH Key Format
|
||||
|
||||
When loading SSH keys, you can specify the format using the `ssh-format` query parameter. This is useful when you need the private key in a specific format like OpenSSH.
|
||||
|
||||
```yml
|
||||
on: push
|
||||
jobs:
|
||||
hello-world:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Load secret
|
||||
uses: 1password/load-secrets-action@v1
|
||||
with:
|
||||
# Export loaded secrets as environment variables
|
||||
export-env: true
|
||||
env:
|
||||
OP_CONNECT_HOST: <Your Connect instance URL>
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
SECRET: op://app-cicd/hello-world/secret
|
||||
|
||||
- name: Print masked secret
|
||||
run: echo "Secret: $SECRET"
|
||||
# Prints: Secret: ***
|
||||
- name: Load SSH key
|
||||
uses: 1password/load-secrets-action@v3
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
# Load SSH private key in OpenSSH format
|
||||
SSH_PRIVATE_KEY: op://vault/item/private key?ssh-format=openssh
|
||||
```
|
||||
|
||||
</details>
|
||||
For more details on secret reference syntax, see the [1Password CLI documentation](https://developer.1password.com/docs/cli/secret-reference-syntax/#ssh-format-parameter).
|
||||
|
||||
<details>
|
||||
<summary><b>Longer usage example</b></summary>
|
||||
## 💙 Community & Support
|
||||
|
||||
```yml
|
||||
on: push
|
||||
name: Deploy app
|
||||
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
|
||||
- Join the [Developer Slack workspace](https://developer.1password.com/joinslack).
|
||||
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Configure 1Password Connect
|
||||
uses: 1password/load-secrets-action/configure@v1
|
||||
with:
|
||||
# Persist the 1Password Connect URL for next steps. You can also persist
|
||||
# the Connect token using input `connect-token`, but keep in mind that
|
||||
# this will grant all steps of the job access to the token.
|
||||
connect-host: https://1password.acme.com
|
||||
|
||||
- name: Load Docker credentials
|
||||
uses: 1password/load-secrets-action@v1
|
||||
with:
|
||||
# Export loaded secrets as environment variables
|
||||
export-env: true
|
||||
env:
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
DOCKERHUB_USERNAME: op://app-cicd/docker/username
|
||||
DOCKERHUB_TOKEN: op://app-cicd/docker/token
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USERNAME }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Print environment variables with masked secrets
|
||||
run: printenv
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
tags: acme/app:latest
|
||||
|
||||
- name: Load AWS credentials
|
||||
uses: 1password/load-secrets-action@v1
|
||||
with:
|
||||
# Export loaded secrets as environment variables
|
||||
export-env: true
|
||||
# Remove local copies of the Docker credentials, which aren't needed anymore
|
||||
unset-previous: true
|
||||
env:
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: op://app-cicd/aws/access-key-id
|
||||
AWS_SECRET_ACCESS_KEY: op://app-cicd/aws/secret-access-key
|
||||
|
||||
- name: Deploy app
|
||||
# This script expects AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set.
|
||||
# This happened using secret references in the preceding lines.
|
||||
run: ./deploy.sh
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Longer usage example with Service Accounts <sup>BETA</sup></b></summary>
|
||||
|
||||
```yml
|
||||
on: push
|
||||
name: Deploy app
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Configure 1Password Connect
|
||||
uses: 1password/load-secrets-action/configure@v1
|
||||
with:
|
||||
# Persist the 1Password Service Account token. This will grant
|
||||
# all steps of the job access to the token.
|
||||
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
|
||||
- name: Load Docker credentials
|
||||
uses: 1password/load-secrets-action@v1
|
||||
with:
|
||||
# Export loaded secrets as environment variables
|
||||
export-env: true
|
||||
env:
|
||||
DOCKERHUB_USERNAME: op://app-cicd/docker/username
|
||||
DOCKERHUB_TOKEN: op://app-cicd/docker/token
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USERNAME }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Print environment variables with masked secrets
|
||||
run: printenv
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
tags: acme/app:latest
|
||||
|
||||
- name: Load AWS credentials
|
||||
uses: 1password/load-secrets-action@v1
|
||||
with:
|
||||
# Export loaded secrets as environment variables
|
||||
export-env: true
|
||||
# Remove local copies of the Docker credentials, which aren't needed anymore
|
||||
unset-previous: true
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: op://app-cicd/aws/access-key-id
|
||||
AWS_SECRET_ACCESS_KEY: op://app-cicd/aws/secret-access-key
|
||||
|
||||
- name: Deploy app
|
||||
# This script expects AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set.
|
||||
# This happened using secret references in the preceding lines.
|
||||
run: ./deploy.sh
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Action Inputs
|
||||
|
||||
| Name | Default | Description |
|
||||
| ---------------- | ------- | ---------------------------------------------------------------------------------- |
|
||||
| `export-env` | `true` | Export the loaded secrets as environment variables |
|
||||
| `unset-previous` | `false` | Whether to unset environment variables populated by 1Password in earlier job steps |
|
||||
|
||||
## Secrets Reference Syntax
|
||||
|
||||
To specify which secret should be loaded into which environment variable, the action will look for `op://` reference URIs in environment variables, and replace those with the actual secret values.
|
||||
|
||||
These reference URIs have the following syntax:
|
||||
|
||||
> `op://<vault>/<item>[/<section>]/<field>`
|
||||
|
||||
So for example, the reference URI `op://app-cicd/aws/secret-access-key` would be interpreted as:
|
||||
|
||||
- **Vault:** `app-cicd`
|
||||
- **Item:** `aws`
|
||||
- **Section:** default section
|
||||
- **Field:** `secret-access-key`
|
||||
|
||||
## Masking
|
||||
|
||||
Similar to regular GitHub repository secrets, fields from 1Password will automatically be masked from the GitHub Actions logs too.
|
||||
So if one of these values accidentally gets printed, it'll get replaced with `***`.
|
||||
|
||||
## 1Password Configuration
|
||||
|
||||
To use the action with Connect, you need to have a [1Password Connect](https://support.1password.com/secrets-automation/#step-1-set-up-a-secrets-automation-workflow) instance deployed somewhere.
|
||||
To configure the action with your Connect host and token, set the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` environment variables.
|
||||
|
||||
To configure the action with your service account token <sup>BETA</sup>, set the `OP_SERVICE_ACCOUNT_TOKEN` environment variable.
|
||||
|
||||
If you're using the `load-secrets` action more than once in a single job, you can use the `configure` action to avoid duplicate configuration:
|
||||
|
||||
```yml
|
||||
on: push
|
||||
jobs:
|
||||
hello-world:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Configure 1Password Connect
|
||||
uses: 1password/load-secrets-action/configure@v1
|
||||
with:
|
||||
connect-host: <Your Connect instance URL>
|
||||
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
- name: Load secret
|
||||
uses: 1password/load-secrets-action@v1
|
||||
env:
|
||||
SECRET: op://app-cicd/hello-world/secret
|
||||
```
|
||||
|
||||
### `configure` Action Inputs
|
||||
|
||||
| Name | Environment variable | Description |
|
||||
| ----------------------- | -------------------------- | -------------------------------------------------------- |
|
||||
| `connect-host` | `OP_CONNECT_HOST` | Your 1Password Connect instance URL |
|
||||
| `connect-token` | `OP_CONNECT_TOKEN` | Token to authenticate to your 1Password Connect instance |
|
||||
| `service-account-token` | `OP_SERVICE_ACCOUNT_TOKEN` | Your 1Password service account token |
|
||||
|
||||
## Supported Runners
|
||||
|
||||
You can run the action on Linux and macOS runners. Windows is currently not supported.
|
||||
|
||||
## Warnings
|
||||
|
||||
If you're using the CLI in your GitHub pipelines and you want to create items with it, the following command will fail:
|
||||
|
||||
```
|
||||
op item create --category=login --title='My Example Item' --vault='Test' \
|
||||
--url https://www.acme.com/login \
|
||||
--generate-password=20,letters,digits \
|
||||
username=jane@acme.com \
|
||||
'Test Field 1=my test secret' \
|
||||
'Test Section 1.Test Field2[text]=Jane Doe' \
|
||||
'Test Section 1.Test Field3[date]=1995-02-23' \
|
||||
'Test Section 2.Test Field4[text]='$myNotes
|
||||
```
|
||||
|
||||
This is caused by the fact that the environment in these pipelines is in piped mode, which triggers the CLI's pipe detection to expect a piped input.
|
||||
To be able to create items in such environments, do the following steps:
|
||||
|
||||
1. Get the template of the item category you want:
|
||||
|
||||
```sh
|
||||
op item template get --out-file=new-item.json <category>
|
||||
```
|
||||
|
||||
2. Edit [the template](https://developer.1password.com/docs/cli/item-template-json) to add your information.
|
||||
3. Pipe the item content to the command:
|
||||
|
||||
```sh
|
||||
cat new-item.json | op item create --vault='Test'
|
||||
```
|
||||
|
||||
## Security
|
||||
## 🔐 Security
|
||||
|
||||
1Password requests you practice responsible disclosure if you discover a vulnerability.
|
||||
|
||||
Please file requests through [BugCrowd](https://bugcrowd.com/agilebits).
|
||||
|
||||
For information about our security practices, visit the [1Password Security homepage](https://1password.com/security).
|
||||
|
||||
## Getting help
|
||||
|
||||
If you find yourself stuck, visit our [**Support Page**](https://support.1password.com/) for help.
|
||||
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"
|
||||
|
||||
@@ -10,10 +10,24 @@ const jestConfig = {
|
||||
rootDir: "../src/",
|
||||
testEnvironment: "node",
|
||||
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
|
||||
moduleNameMapper: {
|
||||
"^@actions/core$": "<rootDir>/__mocks__/actions-core.ts",
|
||||
"^@actions/tool-cache$": "<rootDir>/__mocks__/actions-tool-cache.ts",
|
||||
"^@actions/exec$": "<rootDir>/__mocks__/actions-exec.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,
|
||||
};
|
||||
|
||||
export default jestConfig;
|
||||
module.exports = jestConfig;
|
||||
|
||||
@@ -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"
|
||||
|
||||
30934
configure/dist/index.js
vendored
Normal file
30934
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 @@
|
||||
import * as core from "@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();
|
||||
38844
dist/index.js
vendored
38844
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
|
||||
```
|
||||
146
entrypoint.sh
146
entrypoint.sh
@@ -1,146 +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() {
|
||||
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}"
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
curl -sSfLo op.zip "https://cache.agilebits.com/dist/1P/op2/pkg/v2.10.0-beta.02/op_linux_amd64_v2.10.0-beta.02.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/v2.10.0-beta.02/op_apple_universal_v2.10.0-beta.02.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
|
||||
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
|
||||
|
||||
if [ "$INPUT_EXPORT_ENV" == "true" ]; then
|
||||
# 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.
|
||||
random_heredoc_identifier=$(openssl rand -hex 32)
|
||||
|
||||
{
|
||||
# Populate env var, using heredoc syntax with generated identifier
|
||||
echo "$env_var<<${random_heredoc_identifier}"
|
||||
echo "$secret_value"
|
||||
echo "${random_heredoc_identifier}"
|
||||
} >> $GITHUB_ENV
|
||||
echo "GITHUB_ENV: $(cat $GITHUB_ENV)"
|
||||
|
||||
else
|
||||
# Prepare the secret_value to be outputed properly (especially multiline secrets)
|
||||
secret_value=$(echo "$secret_value" | awk -v ORS='%0A' '1')
|
||||
|
||||
echo "::set-output name=$env_var::$secret_value"
|
||||
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
|
||||
13021
package-lock.json
generated
13021
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "load-secrets-action",
|
||||
"version": "1.2.0",
|
||||
"version": "3.1.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,27 @@
|
||||
},
|
||||
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/exec": "^1.1.1"
|
||||
"@1password/op-js": "^0.1.11",
|
||||
"@1password/sdk": "^0.4.0",
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/exec": "^3.0.0",
|
||||
"@actions/tool-cache": "^4.0.0",
|
||||
"dotenv": "^17.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@1password/front-end-style": "^6.0.1",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^18.15.10",
|
||||
"@vercel/ncc": "^0.36.1",
|
||||
"husky": "^8.0.3",
|
||||
"jest": "^29.5.0",
|
||||
"lint-staged": "^13.2.0",
|
||||
"ts-jest": "^29.0.5",
|
||||
"typescript": "^4.9.5"
|
||||
"@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": "^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 +68,5 @@
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
},
|
||||
"prettier": "./node_modules/@1password/front-end-style/prettierrc.json"
|
||||
"prettier": "@1password/prettier-config"
|
||||
}
|
||||
|
||||
14
src/__mocks__/actions-core.ts
Normal file
14
src/__mocks__/actions-core.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
getInput: jest.fn(() => ""),
|
||||
getBooleanInput: jest.fn(() => false),
|
||||
setOutput: jest.fn(),
|
||||
setSecret: jest.fn(),
|
||||
exportVariable: jest.fn(),
|
||||
setFailed: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
addPath: jest.fn(),
|
||||
isDebug: jest.fn(() => false),
|
||||
};
|
||||
5
src/__mocks__/actions-exec.ts
Normal file
5
src/__mocks__/actions-exec.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
getExecOutput: jest.fn(() => ({
|
||||
stdout: "MOCK_SECRET",
|
||||
})),
|
||||
};
|
||||
10
src/__mocks__/actions-tool-cache.ts
Normal file
10
src/__mocks__/actions-tool-cache.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
downloadTool: jest.fn(),
|
||||
extractTar: jest.fn(),
|
||||
extractZip: jest.fn(),
|
||||
cacheDir: jest.fn<Promise<string>, [string]>(async (dir) => {
|
||||
await Promise.resolve();
|
||||
return dir;
|
||||
}),
|
||||
find: jest.fn<string, [string, string?, string?]>(() => ""),
|
||||
};
|
||||
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}.`;
|
||||
58
src/index.ts
58
src/index.ts
@@ -1,20 +1,40 @@
|
||||
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, envConnectHost, envConnectToken } 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 });
|
||||
}
|
||||
|
||||
const isConnect =
|
||||
process.env[envConnectHost] && process.env[envConnectToken];
|
||||
// If Connect is used, download and install the CLI
|
||||
if (isConnect) {
|
||||
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 +49,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);
|
||||
}
|
||||
}
|
||||
400
src/utils.test.ts
Normal file
400
src/utils.test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
import { read, setClientInfo } from "@1password/op-js";
|
||||
import { createClient, Secrets } from "@1password/sdk";
|
||||
import {
|
||||
extractSecret,
|
||||
loadSecrets,
|
||||
unsetPrevious,
|
||||
validateAuth,
|
||||
} from "./utils";
|
||||
import {
|
||||
authErr,
|
||||
envConnectHost,
|
||||
envConnectToken,
|
||||
envManagedVariables,
|
||||
envServiceAccountToken,
|
||||
} from "./constants";
|
||||
|
||||
jest.mock("@1password/op-js");
|
||||
jest.mock("@1password/sdk", () => ({
|
||||
createClient: jest.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Secrets: {
|
||||
validateSecretReference: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
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("when secret value is empty string", () => {
|
||||
const emptySecretValue = "";
|
||||
|
||||
beforeEach(() => {
|
||||
(read.parse as jest.Mock).mockReturnValue(emptySecretValue);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(read.parse as jest.Mock).mockReturnValue(testSecretValue);
|
||||
});
|
||||
|
||||
it("should set empty string as step output", () => {
|
||||
extractSecret(envTestSecretEnv, false);
|
||||
expect(core.setOutput).toHaveBeenCalledWith(
|
||||
envTestSecretEnv,
|
||||
emptySecretValue,
|
||||
);
|
||||
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set empty string as environment variable", () => {
|
||||
extractSecret(envTestSecretEnv, true);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||
envTestSecretEnv,
|
||||
emptySecretValue,
|
||||
);
|
||||
expect(core.setOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call setSecret for empty string", () => {
|
||||
extractSecret(envTestSecretEnv, false);
|
||||
expect(core.setSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSecrets when using Connect", () => {
|
||||
beforeEach(() => {
|
||||
process.env[envConnectHost] = "https://localhost:8000";
|
||||
process.env[envConnectToken] = "token";
|
||||
process.env[envServiceAccountToken] = "";
|
||||
});
|
||||
|
||||
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("loadSecrets when using Service Account", () => {
|
||||
const mockResolve = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
process.env[envConnectHost] = "";
|
||||
process.env[envConnectToken] = "";
|
||||
process.env[envServiceAccountToken] = "ops_token";
|
||||
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (
|
||||
typeof process.env[key] === "string" &&
|
||||
process.env[key]?.startsWith("op://")
|
||||
) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
process.env.MY_SECRET = "op://vault/item/field";
|
||||
|
||||
(createClient as jest.Mock).mockResolvedValue({
|
||||
secrets: { resolve: mockResolve },
|
||||
});
|
||||
|
||||
mockResolve.mockResolvedValue("resolved-secret-value");
|
||||
});
|
||||
|
||||
it("does not call op env ls when using Service Account", async () => {
|
||||
await loadSecrets(false);
|
||||
expect(exec.getExecOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets step output with resolved value when export-env is false", async () => {
|
||||
await loadSecrets(false);
|
||||
expect(core.setOutput).toHaveBeenCalledTimes(1);
|
||||
expect(core.setOutput).toHaveBeenCalledWith(
|
||||
"MY_SECRET",
|
||||
"resolved-secret-value",
|
||||
);
|
||||
});
|
||||
|
||||
it("masks secret with setSecret when export-env is false", async () => {
|
||||
await loadSecrets(false);
|
||||
expect(core.setSecret).toHaveBeenCalledTimes(1);
|
||||
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
|
||||
});
|
||||
|
||||
it("does not call exportVariable when export-env is false", async () => {
|
||||
await loadSecrets(false);
|
||||
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exports env and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
|
||||
await loadSecrets(true);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||
"MY_SECRET",
|
||||
"resolved-secret-value",
|
||||
);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||
envManagedVariables,
|
||||
"MY_SECRET",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not set step output when export-env is true", async () => {
|
||||
await loadSecrets(true);
|
||||
expect(core.setOutput).not.toHaveBeenCalledWith(
|
||||
"MY_SECRET",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("masks secret with setSecret when export-env is true", async () => {
|
||||
await loadSecrets(true);
|
||||
expect(core.setSecret).toHaveBeenCalledTimes(1);
|
||||
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
|
||||
});
|
||||
|
||||
it("returns early when no env vars have op:// refs", async () => {
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (
|
||||
typeof process.env[key] === "string" &&
|
||||
process.env[key]?.startsWith("op://")
|
||||
) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
await loadSecrets(true);
|
||||
expect(exec.getExecOutput).not.toHaveBeenCalled();
|
||||
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wraps createClient errors with a descriptive message", async () => {
|
||||
(createClient as jest.Mock).mockRejectedValue(
|
||||
new Error("invalid token format"),
|
||||
);
|
||||
await expect(loadSecrets(false)).rejects.toThrow(
|
||||
"Service account authentication failed: invalid token format",
|
||||
);
|
||||
});
|
||||
|
||||
describe("multiple refs", () => {
|
||||
const ref1 = "op://vault/item/field";
|
||||
const ref2 = "op://vault/other/item";
|
||||
const ref3 = "op://vault/file/secret";
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.MY_SECRET = ref1;
|
||||
process.env.ANOTHER_SECRET = ref2;
|
||||
process.env.FILE_SECRET = ref3;
|
||||
|
||||
mockResolve
|
||||
.mockResolvedValueOnce("value1")
|
||||
.mockResolvedValueOnce("value2")
|
||||
.mockResolvedValueOnce("value3");
|
||||
});
|
||||
|
||||
it("resolves each ref and sets step output for each when export-env is false", async () => {
|
||||
await loadSecrets(false);
|
||||
|
||||
expect(mockResolve).toHaveBeenCalledTimes(3);
|
||||
expect(mockResolve).toHaveBeenCalledWith(ref1);
|
||||
expect(mockResolve).toHaveBeenCalledWith(ref2);
|
||||
expect(mockResolve).toHaveBeenCalledWith(ref3);
|
||||
|
||||
expect(core.setOutput).toHaveBeenCalledTimes(3);
|
||||
expect(core.setOutput).toHaveBeenCalledWith("MY_SECRET", "value1");
|
||||
expect(core.setOutput).toHaveBeenCalledWith("ANOTHER_SECRET", "value2");
|
||||
expect(core.setOutput).toHaveBeenCalledWith("FILE_SECRET", "value3");
|
||||
|
||||
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("resolves each ref and exports each and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
|
||||
await loadSecrets(true);
|
||||
|
||||
expect(mockResolve).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(core.exportVariable).toHaveBeenCalledWith("MY_SECRET", "value1");
|
||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||
"ANOTHER_SECRET",
|
||||
"value2",
|
||||
);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith("FILE_SECRET", "value3");
|
||||
|
||||
const exportVariableCalls = (core.exportVariable as jest.Mock).mock
|
||||
.calls as [string, string][];
|
||||
const managedVarsCall = exportVariableCalls.find(
|
||||
([name]) => name === envManagedVariables,
|
||||
);
|
||||
expect(managedVarsCall).toBeDefined();
|
||||
const managedList = (managedVarsCall as [string, string])[1].split(",");
|
||||
expect(managedList).toContain("MY_SECRET");
|
||||
expect(managedList).toContain("ANOTHER_SECRET");
|
||||
expect(managedList).toContain("FILE_SECRET");
|
||||
expect(managedList).toHaveLength(3);
|
||||
|
||||
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret reference validation", () => {
|
||||
it("fails with clear message when a secret reference is invalid", async () => {
|
||||
process.env.MY_SECRET = "op://x";
|
||||
(Secrets.validateSecretReference as jest.Mock).mockImplementationOnce(
|
||||
() => {
|
||||
throw new Error("invalid reference format");
|
||||
},
|
||||
);
|
||||
|
||||
await expect(loadSecrets(true)).rejects.toThrow(
|
||||
"Invalid secret reference(s): MY_SECRET",
|
||||
);
|
||||
expect(mockResolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates all refs before resolving any secrets", async () => {
|
||||
process.env.MY_SECRET = "op://vault/item/field";
|
||||
process.env.OTHER = "op://vault/other/item";
|
||||
(Secrets.validateSecretReference as jest.Mock).mockImplementation(
|
||||
(ref: string) => {
|
||||
if (ref === "op://vault/other/item") {
|
||||
throw new Error("invalid");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await expect(loadSecrets(false)).rejects.toThrow(
|
||||
"Invalid secret reference(s): OTHER",
|
||||
);
|
||||
expect(mockResolve).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", "");
|
||||
});
|
||||
});
|
||||
194
src/utils.ts
Normal file
194
src/utils.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import * as core from "@actions/core";
|
||||
import * as exec from "@actions/exec";
|
||||
import { read, setClientInfo, semverToInt } from "@1password/op-js";
|
||||
import { createClient, Secrets } from "@1password/sdk";
|
||||
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}.`);
|
||||
};
|
||||
|
||||
const getEnvVarNamesWithSecretRefs = (): string[] =>
|
||||
Object.keys(process.env).filter(
|
||||
(key) =>
|
||||
typeof process.env[key] === "string" &&
|
||||
process.env[key]?.startsWith("op://"),
|
||||
);
|
||||
|
||||
const validateSecretRefs = (envNames: string[]): void => {
|
||||
const invalid: { name: string; message: string }[] = [];
|
||||
|
||||
for (const envName of envNames) {
|
||||
const ref = process.env[envName];
|
||||
if (!ref) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Secrets.validateSecretReference(ref);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
invalid.push({ name: envName, message });
|
||||
}
|
||||
}
|
||||
|
||||
// Throw an error if any secret references are invalid
|
||||
if (invalid.length > 0) {
|
||||
const details = invalid
|
||||
.map(({ name, message }) => `${name}: ${message}`)
|
||||
.join("; ");
|
||||
throw new Error(`Invalid secret reference(s): ${details}`);
|
||||
}
|
||||
};
|
||||
|
||||
const setResolvedSecret = (
|
||||
envName: string,
|
||||
secretValue: string,
|
||||
shouldExportEnv: boolean,
|
||||
): void => {
|
||||
core.info(`Populating variable: ${envName}`);
|
||||
|
||||
if (shouldExportEnv) {
|
||||
core.exportVariable(envName, secretValue);
|
||||
} else {
|
||||
core.setOutput(envName, secretValue);
|
||||
}
|
||||
if (secretValue) {
|
||||
core.setSecret(secretValue);
|
||||
}
|
||||
};
|
||||
|
||||
export const extractSecret = (
|
||||
envName: string,
|
||||
shouldExportEnv: boolean,
|
||||
): void => {
|
||||
const ref = process.env[envName];
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretValue = read.parse(ref);
|
||||
if (secretValue === null || secretValue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvedSecret(envName, secretValue, shouldExportEnv);
|
||||
};
|
||||
|
||||
// Connect loads secrets via the 1Password CLI
|
||||
const loadSecretsViaConnect = async (
|
||||
shouldExportEnv: boolean,
|
||||
): Promise<void> => {
|
||||
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());
|
||||
}
|
||||
};
|
||||
|
||||
// Service Account loads secrets via the 1Password SDK
|
||||
const loadSecretsViaServiceAccount = async (
|
||||
shouldExportEnv: boolean,
|
||||
): Promise<void> => {
|
||||
const envs = getEnvVarNamesWithSecretRefs();
|
||||
if (envs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateSecretRefs(envs);
|
||||
|
||||
const token = process.env[envServiceAccountToken];
|
||||
if (!token) {
|
||||
throw new Error(authErr);
|
||||
}
|
||||
|
||||
// Authenticate with the 1Password SDK
|
||||
let client;
|
||||
try {
|
||||
client = await createClient({
|
||||
auth: token,
|
||||
integrationName: "1Password GitHub Action",
|
||||
integrationVersion: version,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Service account authentication failed: ${message}`);
|
||||
}
|
||||
|
||||
for (const envName of envs) {
|
||||
const ref = process.env[envName];
|
||||
if (!ref) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the secret value using the 1Password SDK
|
||||
// and make it available either as step outputs or as environment variables
|
||||
const secretValue = await client.secrets.resolve(ref);
|
||||
setResolvedSecret(envName, secretValue, shouldExportEnv);
|
||||
}
|
||||
|
||||
if (shouldExportEnv) {
|
||||
core.exportVariable(envManagedVariables, envs.join());
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
||||
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
|
||||
|
||||
if (isConnect) {
|
||||
await loadSecretsViaConnect(shouldExportEnv);
|
||||
return;
|
||||
}
|
||||
|
||||
await loadSecretsViaServiceAccount(shouldExportEnv);
|
||||
};
|
||||
|
||||
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, "");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
7
tests/assert-invalid-ref-failed.sh
Executable file
7
tests/assert-invalid-ref-failed.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if [ "$STEP_OUTCOME" != "failure" ]; then
|
||||
echo "Expected action to fail on invalid ref, got: $STEP_OUTCOME"
|
||||
exit 1
|
||||
fi
|
||||
echo "Action correctly failed on invalid ref"
|
||||
@@ -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