Compare commits
190 Commits
v1.0.1
...
vzt/prepar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4e59e3d45 | ||
|
|
c2b96b53cd | ||
|
|
6f52eddca2 | ||
|
|
dc5cd4d17f | ||
|
|
b4962e1861 | ||
|
|
2f243ca4fa | ||
|
|
c96389a7ae | ||
|
|
ba38da7905 | ||
|
|
6961848b51 | ||
|
|
fac78884c8 | ||
|
|
2c0496a719 | ||
|
|
483f83267a | ||
|
|
0ee5bc7530 | ||
|
|
fee9db6b39 | ||
|
|
1824b2f006 | ||
|
|
0f3110274c | ||
|
|
80f581e4b5 | ||
|
|
74df766d96 | ||
|
|
df80909445 | ||
|
|
e3aa72700f | ||
|
|
6a721fb6aa | ||
|
|
7ee331322f | ||
|
|
9fb38d43e1 | ||
|
|
bdf1f8ceff | ||
|
|
7c3deef5f9 | ||
|
|
3048b822db | ||
|
|
564bf5b01f | ||
|
|
0ff92dd768 | ||
|
|
1850a6b487 | ||
|
|
08a0af8ec3 | ||
|
|
d11f2d1dac | ||
|
|
2c12b97549 | ||
|
|
211132e91f | ||
|
|
13f58eec61 | ||
|
|
f9847b316a | ||
|
|
438a01224c | ||
|
|
ba891d4bf2 | ||
|
|
909c7e01f1 | ||
|
|
1c443d83da | ||
|
|
867fee7815 | ||
|
|
ee4b4919bf | ||
|
|
13f110716c | ||
|
|
7914f19c4d | ||
|
|
25aa72f51b | ||
|
|
2fa8a509ca | ||
|
|
f30ae37660 | ||
|
|
93b787fdef | ||
|
|
29afdd3b50 | ||
|
|
c03c0b6bbe | ||
|
|
cb2930c65f | ||
|
|
65a7f5e592 | ||
|
|
a8d5f2a285 | ||
|
|
f5fc2382af | ||
|
|
596d8007a1 | ||
|
|
c9ae724dfd | ||
|
|
40b6ef7b57 | ||
|
|
a4866d442c | ||
|
|
10084cd57d | ||
|
|
d6b7427345 | ||
|
|
96de656797 | ||
|
|
c0724d8845 | ||
|
|
3a62b7cf63 | ||
|
|
43fd9cdb84 | ||
|
|
73195c1d43 | ||
|
|
85e0e789db | ||
|
|
39cf694bee | ||
|
|
39b7248332 | ||
|
|
a5e5c78980 | ||
|
|
7d16183347 | ||
|
|
0cbceff209 | ||
|
|
fec5c39dcc | ||
|
|
a525a84c53 | ||
|
|
6483669c68 | ||
|
|
06962f2427 | ||
|
|
3e2909a6b2 | ||
|
|
734cd437f8 | ||
|
|
555e0c6a63 | ||
|
|
0a309926fa | ||
|
|
a51c02d593 | ||
|
|
d36634f96f | ||
|
|
a8494ee438 | ||
|
|
904025a654 | ||
|
|
581a835fb5 | ||
|
|
cf1a288161 | ||
|
|
2792fede48 | ||
|
|
b575844081 | ||
|
|
c7a1c1e3bb | ||
|
|
a2a357a196 | ||
|
|
d1a4e73495 | ||
|
|
83858b7236 | ||
|
|
10ed0757b7 | ||
|
|
539eaa66ee | ||
|
|
08315da4b3 | ||
|
|
0e91b4a315 | ||
|
|
9c2d98ed07 | ||
|
|
d8ac5d7286 | ||
|
|
15d95ae871 | ||
|
|
a48d1fcd00 | ||
|
|
8fd274c5eb | ||
|
|
f4303b27ca | ||
|
|
f4a99d4598 | ||
|
|
9c1afd6054 | ||
|
|
a02ee663cc | ||
|
|
663ac229cb | ||
|
|
9bb44334eb | ||
|
|
1ec261f63f | ||
|
|
2e386ac304 | ||
|
|
0b706bbe43 | ||
|
|
e23df52c69 | ||
|
|
ade3078eb5 | ||
|
|
4ee6567f7b | ||
|
|
27b96b5fd8 | ||
|
|
14687e434a | ||
|
|
1a17146422 | ||
|
|
94dcd16f05 | ||
|
|
4cd70e1a09 | ||
|
|
b30803866e | ||
|
|
feb6ed7c04 | ||
|
|
d669a8ba06 | ||
|
|
936f62b7d9 | ||
|
|
95681075d8 | ||
|
|
0a7975f916 | ||
|
|
ffba2a6966 | ||
|
|
2ee4979efa | ||
|
|
7903600d82 | ||
|
|
fbf9be8f55 | ||
|
|
5a04ae581c | ||
|
|
747c0b5974 | ||
|
|
c0fbfd88d3 | ||
|
|
3f3d1e45cb | ||
|
|
b73c8a7ca6 | ||
|
|
da6de9b6b3 | ||
|
|
7610c5737f | ||
|
|
30b5930e91 | ||
|
|
c2c5cda6a2 | ||
|
|
351ed34750 | ||
|
|
be04d82018 | ||
|
|
8c15b6c54d | ||
|
|
cb83ae04bf | ||
|
|
e3b137e007 | ||
|
|
30def81a24 | ||
|
|
ed1f9a48af | ||
|
|
b1b82d7384 | ||
|
|
a37c95c8d0 | ||
|
|
aaee1916c6 | ||
|
|
c53c263a7e | ||
|
|
7d858c7ad5 | ||
|
|
ce8b31d0b9 | ||
|
|
2ac8886444 | ||
|
|
641b0d93ec | ||
|
|
c27a045581 | ||
|
|
953b51736f | ||
|
|
2f338e16af | ||
|
|
e9bd76c87a | ||
|
|
d5280efa32 | ||
|
|
8d99fc2a1e | ||
|
|
5c5bbcbaf0 | ||
|
|
e1b37a5b1e | ||
|
|
2a214a29d3 | ||
|
|
2a4f64c09d | ||
|
|
df0b228eb9 | ||
|
|
8052f86fc6 | ||
|
|
34bed60a89 | ||
|
|
ada03a0325 | ||
|
|
c7774c7068 | ||
|
|
1263fc888c | ||
|
|
858b6a838e | ||
|
|
b38b493d73 | ||
|
|
bb28cdf0c6 | ||
|
|
302881bfef | ||
|
|
9fdfd04a6f | ||
|
|
b38d01591f | ||
|
|
d78889ad20 | ||
|
|
6c02b8909f | ||
|
|
cb24269fc9 | ||
|
|
300c776a97 | ||
|
|
2faffa0507 | ||
|
|
d50df7cd6d | ||
|
|
a5debe1b2e | ||
|
|
da5dd0865d | ||
|
|
4af3346b6a | ||
|
|
fae9e58c4f | ||
|
|
000522e32f | ||
|
|
4baca64066 | ||
|
|
ac12d2e3c4 | ||
|
|
e8da10d005 | ||
|
|
5add51bcb8 | ||
|
|
08df44393f | ||
|
|
478705935c | ||
|
|
e64093d691 |
118
.github/workflows/acceptance-test.yml
vendored
Normal file
118
.github/workflows/acceptance-test.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
name: Acceptance test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
secret:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secret-in-section:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
multiline-secret:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
export-env:
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
version:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "latest"
|
||||||
|
os:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
default: "ubuntu-latest"
|
||||||
|
auth:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
acceptance-test:
|
||||||
|
runs-on: ${{ inputs.os }}
|
||||||
|
steps:
|
||||||
|
- name: Base checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
if: |
|
||||||
|
github.event_name != 'repository_dispatch' &&
|
||||||
|
(
|
||||||
|
github.ref == 'refs/heads/main' ||
|
||||||
|
(
|
||||||
|
github.event_name == 'pull_request' &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
)
|
||||||
|
)
|
||||||
|
- name: Fork based /ok-to-test checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.client_payload.pull_request.head.sha }}
|
||||||
|
if: |
|
||||||
|
github.event_name == 'repository_dispatch' &&
|
||||||
|
github.event.client_payload.slash_command.args.named.sha != '' &&
|
||||||
|
contains(
|
||||||
|
github.event.client_payload.pull_request.head.sha,
|
||||||
|
github.event.client_payload.slash_command.args.named.sha
|
||||||
|
)
|
||||||
|
- name: Launch 1Password Connect instance
|
||||||
|
if: ${{ inputs.auth == 'connect' }}
|
||||||
|
env:
|
||||||
|
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||||
|
run: |
|
||||||
|
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
||||||
|
docker compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
|
||||||
|
- name: Configure Service account
|
||||||
|
if: ${{ inputs.auth == 'service-account' }}
|
||||||
|
uses: ./configure
|
||||||
|
with:
|
||||||
|
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
- name: Verify Service Account env var is set
|
||||||
|
if: ${{ inputs.auth == 'service-account' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -z "${OP_SERVICE_ACCOUNT_TOKEN}" ]; then
|
||||||
|
echo "OP_SERVICE_ACCOUNT_TOKEN environment variable is not set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- name: Configure 1Password Connect
|
||||||
|
if: ${{ inputs.auth == 'connect' }}
|
||||||
|
uses: ./configure # 1password/load-secrets-action/configure@<version>
|
||||||
|
with:
|
||||||
|
connect-host: http://localhost:8080
|
||||||
|
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||||
|
- name: Verify Connect env vars are set
|
||||||
|
if: ${{ inputs.auth == 'connect' }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$OP_CONNECT_HOST" ] || [ -z "$OP_CONNECT_TOKEN" ]; then
|
||||||
|
echo "OP_CONNECT_HOST or OP_CONNECT_TOKEN environment variables are not set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- name: Load secrets
|
||||||
|
id: load_secrets
|
||||||
|
uses: ./ # 1password/load-secrets-action@<version>
|
||||||
|
with:
|
||||||
|
version: ${{ inputs.version }}
|
||||||
|
export-env: ${{ inputs.export-env }}
|
||||||
|
env:
|
||||||
|
SECRET: ${{ inputs.secret }}
|
||||||
|
SECRET_IN_SECTION: ${{ inputs.secret-in-section }}
|
||||||
|
MULTILINE_SECRET: ${{ inputs.multiline-secret }}
|
||||||
|
OP_ENV_FILE: ./tests/.env.tpl
|
||||||
|
- name: Assert test secret values [step output]
|
||||||
|
if: ${{ !inputs.export-env }}
|
||||||
|
env:
|
||||||
|
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
|
||||||
|
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
|
||||||
|
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
|
||||||
|
OP_ENV_FILE: ./tests/.env.tpl
|
||||||
|
run: ./tests/assert-env-set.sh
|
||||||
|
- name: Assert test secret values [exported env]
|
||||||
|
if: ${{ inputs.export-env }}
|
||||||
|
run: ./tests/assert-env-set.sh
|
||||||
|
- name: Remove secrets [exported env]
|
||||||
|
if: ${{ inputs.export-env }}
|
||||||
|
uses: ./ # 1password/load-secrets-action@<version>
|
||||||
|
with:
|
||||||
|
unset-previous: true
|
||||||
|
- name: Assert removed secrets [exported env]
|
||||||
|
if: ${{ inputs.export-env }}
|
||||||
|
run: ./tests/assert-env-unset.sh
|
||||||
160
.github/workflows/e2e-tests.yml
vendored
Normal file
160
.github/workflows/e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
# For local testing with: act push -W .github/workflows/e2e-tests.yml
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- "**" # Never runs on GitHub, only locally with act
|
||||||
|
|
||||||
|
# For test.yml to call this workflow
|
||||||
|
workflow_call:
|
||||||
|
secrets:
|
||||||
|
OP_CONNECT_CREDENTIALS:
|
||||||
|
required: true
|
||||||
|
OP_CONNECT_TOKEN:
|
||||||
|
required: true
|
||||||
|
OP_SERVICE_ACCOUNT_TOKEN:
|
||||||
|
required: true
|
||||||
|
VAULT:
|
||||||
|
description: "1Password vault name or UUID"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-service-account:
|
||||||
|
name: Service Account (${{ matrix.os }}, ${{ matrix.version }}, export-env=${{ matrix.export-env }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
version: [latest, 2.30.0]
|
||||||
|
export-env: [true, false]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Generate .env.tpl
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "FILE_SECRET=op://${{ secrets.VAULT }}/test-secret/password" > tests/.env.tpl
|
||||||
|
echo "FILE_SECRET_IN_SECTION=op://${{ secrets.VAULT }}/test-secret/test-section/password" >> tests/.env.tpl
|
||||||
|
echo "FILE_MULTILINE_SECRET=op://${{ secrets.VAULT }}/multiline-secret/notesPlain" >> tests/.env.tpl
|
||||||
|
|
||||||
|
- name: Configure Service account
|
||||||
|
uses: ./configure
|
||||||
|
with:
|
||||||
|
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
|
||||||
|
- name: Load secrets
|
||||||
|
id: load_secrets
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
version: ${{ matrix.version }}
|
||||||
|
export-env: ${{ matrix.export-env }}
|
||||||
|
env:
|
||||||
|
SECRET: op://${{ secrets.VAULT }}/test-secret/password
|
||||||
|
SECRET_IN_SECTION: op://${{ secrets.VAULT }}/test-secret/test-section/password
|
||||||
|
MULTILINE_SECRET: op://${{ secrets.VAULT }}/multiline-secret/notesPlain
|
||||||
|
OP_ENV_FILE: ./tests/.env.tpl
|
||||||
|
|
||||||
|
- name: Assert test secret values [step output]
|
||||||
|
if: ${{ !matrix.export-env }}
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
|
||||||
|
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
|
||||||
|
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
|
||||||
|
FILE_SECRET: ${{ steps.load_secrets.outputs.FILE_SECRET }}
|
||||||
|
FILE_SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.FILE_SECRET_IN_SECTION }}
|
||||||
|
FILE_MULTILINE_SECRET: ${{ steps.load_secrets.outputs.FILE_MULTILINE_SECRET }}
|
||||||
|
run: ./tests/assert-env-set.sh
|
||||||
|
|
||||||
|
- name: Assert test secret values [exported env]
|
||||||
|
if: ${{ matrix.export-env }}
|
||||||
|
shell: bash
|
||||||
|
run: ./tests/assert-env-set.sh
|
||||||
|
|
||||||
|
- name: Remove secrets [exported env]
|
||||||
|
if: ${{ matrix.export-env }}
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
unset-previous: true
|
||||||
|
|
||||||
|
- name: Assert removed secrets [exported env]
|
||||||
|
if: ${{ matrix.export-env }}
|
||||||
|
shell: bash
|
||||||
|
run: ./tests/assert-env-unset.sh
|
||||||
|
|
||||||
|
test-connect:
|
||||||
|
name: Connect (ubuntu-latest, ${{ matrix.version }}, export-env=${{ matrix.export-env }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
version: [latest, 2.30.0]
|
||||||
|
export-env: [true, false]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Generate .env.tpl
|
||||||
|
run: |
|
||||||
|
mkdir -p tests
|
||||||
|
echo "FILE_SECRET=op://${{ secrets.VAULT }}/test-secret/password" > tests/.env.tpl
|
||||||
|
echo "FILE_SECRET_IN_SECTION=op://${{ secrets.VAULT }}/test-secret/test-section/password" >> tests/.env.tpl
|
||||||
|
echo "FILE_MULTILINE_SECRET=op://${{ secrets.VAULT }}/multiline-secret/notesPlain" >> tests/.env.tpl
|
||||||
|
|
||||||
|
- name: Launch 1Password Connect instance
|
||||||
|
env:
|
||||||
|
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||||
|
run: |
|
||||||
|
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
||||||
|
docker compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
|
||||||
|
|
||||||
|
- name: Configure 1Password Connect
|
||||||
|
uses: ./configure
|
||||||
|
with:
|
||||||
|
connect-host: http://localhost:8080
|
||||||
|
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||||
|
|
||||||
|
- name: Load secrets
|
||||||
|
id: load_secrets
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
version: ${{ matrix.version }}
|
||||||
|
export-env: ${{ matrix.export-env }}
|
||||||
|
env:
|
||||||
|
SECRET: op://${{ secrets.VAULT }}/test-secret/password
|
||||||
|
SECRET_IN_SECTION: op://${{ secrets.VAULT }}/test-secret/test-section/password
|
||||||
|
MULTILINE_SECRET: op://${{ secrets.VAULT }}/multiline-secret/notesPlain
|
||||||
|
OP_ENV_FILE: ./tests/.env.tpl
|
||||||
|
|
||||||
|
- name: Assert test secret values [step output]
|
||||||
|
if: ${{ !matrix.export-env }}
|
||||||
|
env:
|
||||||
|
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
|
||||||
|
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
|
||||||
|
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
|
||||||
|
FILE_SECRET: ${{ steps.load_secrets.outputs.FILE_SECRET }}
|
||||||
|
FILE_SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.FILE_SECRET_IN_SECTION }}
|
||||||
|
FILE_MULTILINE_SECRET: ${{ steps.load_secrets.outputs.FILE_MULTILINE_SECRET }}
|
||||||
|
run: ./tests/assert-env-set.sh
|
||||||
|
|
||||||
|
- name: Assert test secret values [exported env]
|
||||||
|
if: ${{ matrix.export-env }}
|
||||||
|
run: ./tests/assert-env-set.sh
|
||||||
|
|
||||||
|
- name: Remove secrets [exported env]
|
||||||
|
if: ${{ matrix.export-env }}
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
unset-previous: true
|
||||||
|
|
||||||
|
- name: Assert removed secrets [exported env]
|
||||||
|
if: ${{ matrix.export-env }}
|
||||||
|
run: ./tests/assert-env-unset.sh
|
||||||
36
.github/workflows/lint-and-test.yml
vendored
Normal file
36
.github/workflows/lint-and-test.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Lint and Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Run ShellCheck
|
||||||
|
uses: ludeeus/action-shellcheck@2.0.0
|
||||||
|
with:
|
||||||
|
ignore_paths: >-
|
||||||
|
.husky
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: npm run format:check
|
||||||
|
|
||||||
|
- name: Check lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm test
|
||||||
27
.github/workflows/lint.yml
vendored
27
.github/workflows/lint.yml
vendored
@@ -1,10 +1,29 @@
|
|||||||
on: pull_request
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
name: Lint
|
name: Lint
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: ShellCheck
|
- name: Run ShellCheck
|
||||||
uses: ludeeus/action-shellcheck@1.1.0
|
uses: ludeeus/action-shellcheck@2.0.0
|
||||||
|
with:
|
||||||
|
ignore_paths: >-
|
||||||
|
.husky
|
||||||
|
- name: Setup Node.js
|
||||||
|
id: setup-node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
- name: Install Dependencies
|
||||||
|
id: install
|
||||||
|
run: npm ci
|
||||||
|
- name: Check formatting
|
||||||
|
run: npm run format:check
|
||||||
|
- name: Check lint
|
||||||
|
run: npm run lint
|
||||||
|
|||||||
25
.github/workflows/ok-to-test.yml
vendored
Normal file
25
.github/workflows/ok-to-test.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Write comments "/ok-to-test sha=<hash>" on a pull request. This will emit a repository_dispatch event.
|
||||||
|
name: Ok To Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ok-to-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write # For adding reactions to the pull request comments
|
||||||
|
contents: write # For executing the repository_dispatch event
|
||||||
|
# Only run for PRs, not issue comments
|
||||||
|
if: ${{ github.event.issue.pull_request }}
|
||||||
|
steps:
|
||||||
|
- name: Slash Command Dispatch
|
||||||
|
uses: peter-evans/slash-command-dispatch@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
reaction-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
issue-type: pull-request
|
||||||
|
commands: ok-to-test
|
||||||
|
# The repository permission level required by the user to dispatch commands. Only allows 1Password collaborators to run this.
|
||||||
|
permission: write
|
||||||
13
.github/workflows/pr-check-signed-commits.yml
vendored
Normal file
13
.github/workflows/pr-check-signed-commits.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name: Check signed commits in PR
|
||||||
|
on: pull_request_target
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Check signed commits in PR
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check signed commits in PR
|
||||||
|
uses: 1Password/check-signed-commits-action@v1
|
||||||
114
.github/workflows/test-e2e.yml
vendored
Normal file
114
.github/workflows/test-e2e.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore: &ignore_paths
|
||||||
|
- "docs/**"
|
||||||
|
- "config/**"
|
||||||
|
- "*.md"
|
||||||
|
- ".gitignore"
|
||||||
|
- "LICENSE"
|
||||||
|
pull_request:
|
||||||
|
paths-ignore: *ignore_paths
|
||||||
|
repository_dispatch:
|
||||||
|
types: [ok-to-test-command]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: >-
|
||||||
|
${{ github.event_name == 'pull_request' &&
|
||||||
|
format('e2e-{0}', github.event.pull_request.head.ref) ||
|
||||||
|
format('e2e-{0}', github.ref) }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-external-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
condition: ${{ steps.check.outputs.condition }}
|
||||||
|
steps:
|
||||||
|
- name: Check if PR is from external contributor
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
echo "Event name: ${{ github.event_name }}"
|
||||||
|
echo "Repository: ${{ github.repository }}"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||||
|
# For pull_request events, check if PR is from external fork
|
||||||
|
echo "PR head repo: ${{ github.event.pull_request.head.repo.full_name }}"
|
||||||
|
if [ "${{ github.actor }}" == "dependabot[bot]" ]; then
|
||||||
|
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||||
|
echo "Setting condition=skip (Dependabot PR)"
|
||||||
|
elif [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
|
||||||
|
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||||
|
echo "Setting condition=skip (external fork PR creation)"
|
||||||
|
else
|
||||||
|
echo "condition=pr-creation-maintainer" >> $GITHUB_OUTPUT
|
||||||
|
echo "Setting condition=pr-creation-maintainer (internal PR creation)"
|
||||||
|
fi
|
||||||
|
elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then
|
||||||
|
# For repository_dispatch events (ok-to-test), check if sha matches
|
||||||
|
SHA_PARAM="${{ github.event.client_payload.slash_command.args.named.sha }}"
|
||||||
|
PR_HEAD_SHA="${{ github.event.client_payload.pull_request.head.sha }}"
|
||||||
|
|
||||||
|
echo "Checking dispatch event conditions..."
|
||||||
|
echo "SHA from command: $SHA_PARAM"
|
||||||
|
echo "PR head SHA: $PR_HEAD_SHA"
|
||||||
|
|
||||||
|
if [ -n "$SHA_PARAM" ] && [[ "$PR_HEAD_SHA" == *"$SHA_PARAM"* ]]; then
|
||||||
|
echo "condition=dispatch-event" >> $GITHUB_OUTPUT
|
||||||
|
echo "Setting condition=dispatch-event (sha matches)"
|
||||||
|
else
|
||||||
|
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||||
|
echo "Setting condition=skip (sha does not match or empty)"
|
||||||
|
fi
|
||||||
|
elif [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref_name }}" == "main" ]; then
|
||||||
|
echo "condition=push-to-main" >> $GITHUB_OUTPUT
|
||||||
|
echo "Setting condition=push-to-main (push to main)"
|
||||||
|
else
|
||||||
|
# Unknown event type
|
||||||
|
echo "condition=skip" >> $GITHUB_OUTPUT
|
||||||
|
echo "Setting condition=skip (unknown event type: ${{ github.event_name }})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
needs: check-external-pr
|
||||||
|
if: |
|
||||||
|
(needs.check-external-pr.outputs.condition == 'pr-creation-maintainer')
|
||||||
|
||
|
||||||
|
(needs.check-external-pr.outputs.condition == 'dispatch-event')
|
||||||
|
||
|
||||||
|
needs.check-external-pr.outputs.condition == 'push-to-main'
|
||||||
|
uses: ./.github/workflows/e2e-tests.yml
|
||||||
|
secrets:
|
||||||
|
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||||
|
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||||
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
VAULT: ${{ secrets.VAULT }}
|
||||||
|
|
||||||
|
# Post comment on fork PRs after /ok-to-test
|
||||||
|
comment-pr:
|
||||||
|
needs: [check-external-pr, e2e]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: always() && needs.check-external-pr.outputs.condition == 'dispatch-event'
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Create URL to the run output
|
||||||
|
id: vars
|
||||||
|
run: echo "run-url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create comment on PR
|
||||||
|
uses: peter-evans/create-or-update-comment@v5
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.client_payload.pull_request.number }}
|
||||||
|
body: |
|
||||||
|
${{
|
||||||
|
needs.e2e.result == 'success' && '✅ E2E tests passed.' ||
|
||||||
|
needs.e2e.result == 'failure' && '❌ E2E tests failed.' ||
|
||||||
|
'⚠️ E2E tests completed.'
|
||||||
|
}}
|
||||||
|
|
||||||
|
[View test run output][1]
|
||||||
|
|
||||||
|
[1]: ${{ steps.vars.outputs.run-url }}
|
||||||
92
.github/workflows/test-fork.yml
vendored
Normal file
92
.github/workflows/test-fork.yml
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
on:
|
||||||
|
repository_dispatch:
|
||||||
|
types: [ok-to-test-command]
|
||||||
|
name: Run acceptance tests [fork]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-with-output-secrets:
|
||||||
|
if: |
|
||||||
|
github.event_name == 'repository_dispatch' &&
|
||||||
|
github.event.client_payload.slash_command.args.named.sha != '' &&
|
||||||
|
contains(
|
||||||
|
github.event.client_payload.pull_request.head.sha,
|
||||||
|
github.event.client_payload.slash_command.args.named.sha
|
||||||
|
)
|
||||||
|
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
secret: op://acceptance-tests/test-secret/password
|
||||||
|
secret-in-section: op://acceptance-tests/test-secret/test-section/password
|
||||||
|
multiline-secret: op://acceptance-tests/multiline-secret/notesPlain
|
||||||
|
export-env: false
|
||||||
|
test-with-export-env:
|
||||||
|
if: |
|
||||||
|
github.event_name == 'repository_dispatch' &&
|
||||||
|
github.event.client_payload.slash_command.args.named.sha != '' &&
|
||||||
|
contains(
|
||||||
|
github.event.client_payload.pull_request.head.sha,
|
||||||
|
github.event.client_payload.slash_command.args.named.sha
|
||||||
|
)
|
||||||
|
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
secret: op://acceptance-tests/test-secret/password
|
||||||
|
secret-in-section: op://acceptance-tests/test-secret/test-section/password
|
||||||
|
multiline-secret: op://acceptance-tests/multiline-secret/notesPlain
|
||||||
|
export-env: true
|
||||||
|
test-references-with-ids:
|
||||||
|
if: |
|
||||||
|
github.event_name == 'repository_dispatch' &&
|
||||||
|
github.event.client_payload.slash_command.args.named.sha != '' &&
|
||||||
|
contains(
|
||||||
|
github.event.client_payload.pull_request.head.sha,
|
||||||
|
github.event.client_payload.slash_command.args.named.sha
|
||||||
|
)
|
||||||
|
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
secret: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
|
||||||
|
secret-in-section: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/Section_tco6nsqycj6jcbyx63h5isxcny/doxu3mhkozcznnk5vjrkpdqayy
|
||||||
|
multiline-secret: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
|
||||||
|
export-env: false
|
||||||
|
update-checks:
|
||||||
|
# required permissions for updating the status of the pull request checks
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ always() }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
job-name:
|
||||||
|
[
|
||||||
|
test-with-output-secrets,
|
||||||
|
test-with-export-env,
|
||||||
|
test-references-with-ids,
|
||||||
|
]
|
||||||
|
needs:
|
||||||
|
[test-with-output-secrets, test-with-export-env, test-references-with-ids]
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v6
|
||||||
|
env:
|
||||||
|
job: ${{ matrix.job-name }}
|
||||||
|
ref: ${{ github.event.client_payload.pull_request.head.sha }}
|
||||||
|
conclusion: ${{ needs[format('{0}', matrix.job-name )].result }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const { data: checks } = await github.rest.checks.listForRef({
|
||||||
|
...context.repo,
|
||||||
|
ref: process.env.ref
|
||||||
|
});
|
||||||
|
|
||||||
|
const check = checks.check_runs.filter(c => c.name === process.env.job);
|
||||||
|
|
||||||
|
const { data: result } = await github.rest.checks.update({
|
||||||
|
...context.repo,
|
||||||
|
check_run_id: check[0].id,
|
||||||
|
status: 'completed',
|
||||||
|
conclusion: process.env.conclusion
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
139
.github/workflows/test.yml
vendored
139
.github/workflows/test.yml
vendored
@@ -1,51 +1,100 @@
|
|||||||
on: push
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
name: Run acceptance tests
|
name: Run acceptance tests
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
unit-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Launch 1Password Connect instance
|
- uses: actions/setup-node@v3
|
||||||
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 # 1password/load-secrets-action/configure@<version>
|
|
||||||
with:
|
with:
|
||||||
connect-host: http://localhost:8080
|
node-version: 20
|
||||||
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
- run: npm ci
|
||||||
- name: Load secrets
|
- run: npm test
|
||||||
uses: ./ # 1password/load-secrets-action@<version>
|
|
||||||
env:
|
test-with-output-secrets:
|
||||||
SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
|
if: |
|
||||||
SECRET_IN_SECTION: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/test-section/password
|
github.ref == 'refs/heads/main' ||
|
||||||
UNMASKED_VALUE: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/test-section/username
|
(
|
||||||
- name: Load multiline secret
|
github.event_name == 'pull_request' &&
|
||||||
uses: ./ # 1password/load-secrets-action@<version>
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
env:
|
)
|
||||||
MULTILINE_SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
|
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||||
- name: Print environment variables with masked secrets
|
secrets: inherit
|
||||||
run: printenv
|
strategy:
|
||||||
- name: Assert test secret values
|
matrix:
|
||||||
run: ./tests/assert-env-set.sh
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
- name: Remove secrets
|
version: [latest, latest-beta, 2.30.0, 2.30.0-beta.03]
|
||||||
uses: ./ # 1password/load-secrets-action@<version>
|
auth: [connect, service-account]
|
||||||
with:
|
exclude:
|
||||||
unset-previous: true
|
- os: macos-latest
|
||||||
- name: Print environment variables with secrets removed
|
auth: connect
|
||||||
run: printenv
|
- os: windows-latest
|
||||||
- name: Assert removed secrets
|
auth: connect
|
||||||
run: ./tests/assert-env-unset.sh
|
with:
|
||||||
- name: Load secrets by vault and item titles
|
os: ${{ matrix.os }}
|
||||||
uses: ./ # 1password/load-secrets-action@<version>
|
version: ${{ matrix.version }}
|
||||||
env:
|
auth: ${{ matrix.auth }}
|
||||||
SECRET: op://acceptance-tests/test-secret/password
|
secret: op://acceptance-tests/test-secret/password
|
||||||
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
|
secret-in-section: op://acceptance-tests/test-secret/test-section/password
|
||||||
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
|
multiline-secret: op://acceptance-tests/multiline-secret/notesPlain
|
||||||
- name: Print environment variables with masked secrets
|
export-env: false
|
||||||
run: printenv
|
|
||||||
- name: Assert test secret values again
|
test-with-export-env:
|
||||||
run: ./tests/assert-env-set.sh
|
if: |
|
||||||
|
github.ref == 'refs/heads/main' ||
|
||||||
|
(
|
||||||
|
github.event_name == 'pull_request' &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
)
|
||||||
|
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||||
|
secrets: inherit
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
version: [latest, latest-beta, 2.30.0, 2.30.0-beta.03]
|
||||||
|
auth: [connect, service-account]
|
||||||
|
exclude:
|
||||||
|
- os: macos-latest
|
||||||
|
auth: connect
|
||||||
|
- os: windows-latest
|
||||||
|
auth: connect
|
||||||
|
with:
|
||||||
|
os: ${{ matrix.os }}
|
||||||
|
version: ${{ matrix.version }}
|
||||||
|
auth: ${{ matrix.auth }}
|
||||||
|
secret: op://acceptance-tests/test-secret/password
|
||||||
|
secret-in-section: op://acceptance-tests/test-secret/test-section/password
|
||||||
|
multiline-secret: op://acceptance-tests/multiline-secret/notesPlain
|
||||||
|
export-env: true
|
||||||
|
|
||||||
|
test-references-with-ids:
|
||||||
|
if: |
|
||||||
|
github.ref == 'refs/heads/main' ||
|
||||||
|
(
|
||||||
|
github.event_name == 'pull_request' &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
)
|
||||||
|
uses: 1password/load-secrets-action/.github/workflows/acceptance-test.yml@main
|
||||||
|
secrets: inherit
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
version: [latest, latest-beta, 2.30.0, 2.30.0-beta.03]
|
||||||
|
auth: [connect, service-account]
|
||||||
|
exclude:
|
||||||
|
- os: macos-latest
|
||||||
|
auth: connect
|
||||||
|
- os: windows-latest
|
||||||
|
auth: connect
|
||||||
|
with:
|
||||||
|
os: ${{ matrix.os }}
|
||||||
|
version: ${{ matrix.version }}
|
||||||
|
auth: ${{ matrix.auth }}
|
||||||
|
secret: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
|
||||||
|
secret-in-section: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/Section_tco6nsqycj6jcbyx63h5isxcny/doxu3mhkozcznnk5vjrkpdqayy
|
||||||
|
multiline-secret: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
|
||||||
|
export-env: false
|
||||||
|
|||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +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)
|
||||||
166
README.md
166
README.md
@@ -1,10 +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"/>
|
||||||
|
|
||||||
The action to load secrets from [1Password Connect](https://1password.com/secrets/) into GitHub Actions.
|
<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>
|
||||||
|
|
||||||
Specify right from your workflow YAML which secrets from 1Password should be loaded into your job, and the action will make them available as environment variables for the next steps.
|
---
|
||||||
|
|
||||||
## Usage
|
`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).
|
||||||
|
|
||||||
|
## 🪄 See it in action!
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=kVBl5iQYgSA "Using 1Password Service Accounts with GitHub Actions")
|
||||||
|
|
||||||
|
## ✨ Quickstart
|
||||||
|
|
||||||
|
### Export secrets as a step's output (recommended)
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
on: push
|
on: push
|
||||||
@@ -12,113 +31,22 @@ jobs:
|
|||||||
hello-world:
|
hello-world:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Load secret
|
- name: Load secret
|
||||||
uses: 1password/load-secrets-action@v1
|
id: load_secrets
|
||||||
|
uses: 1password/load-secrets-action@v3
|
||||||
env:
|
env:
|
||||||
OP_CONNECT_HOST: <Your Connect instance URL>
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
|
||||||
SECRET: op://app-cicd/hello-world/secret
|
SECRET: op://app-cicd/hello-world/secret
|
||||||
|
OP_ENV_FILE: "./path/to/.env.tpl" # see tests/.env.tpl for example
|
||||||
|
|
||||||
- name: Print masked secret
|
- name: Print masked secret
|
||||||
run: echo "Secret: $SECRET"
|
run: 'echo "Secret: ${{ steps.load_secrets.outputs.SECRET }}"'
|
||||||
# Prints: Secret: ***
|
# Prints: Secret: ***
|
||||||
```
|
```
|
||||||
<details>
|
|
||||||
<summary><b>Longer usage example</b></summary>
|
|
||||||
|
|
||||||
```yml
|
### Export secrets as env variables
|
||||||
on: push
|
|
||||||
name: Deploy app
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- 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
|
|
||||||
# every single step in the job would then be able to access the token.
|
|
||||||
connect-host: https://1password.acme.com
|
|
||||||
|
|
||||||
- name: Load Docker credentials
|
|
||||||
uses: 1password/load-secrets-action@v1
|
|
||||||
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@v1
|
|
||||||
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@v2
|
|
||||||
with:
|
|
||||||
push: true
|
|
||||||
tags: acme/app:latest
|
|
||||||
|
|
||||||
- name: Load AWS credentials
|
|
||||||
uses: 1password/load-secrets-action@v1
|
|
||||||
with:
|
|
||||||
# Remove local copies of the Docker credentials, which are not 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, which was
|
|
||||||
# done automatically by the step above
|
|
||||||
run: ./deploy.sh
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Action Inputs
|
|
||||||
|
|
||||||
| Name | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `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, secret fields from 1Password will automatically be masked from the GitHub Actions logs too.
|
|
||||||
A 1Password field is considered 'secret' when it's marked as concealed (which shows as `•••••••` in the 1Password GUI) or when it's a secure note.
|
|
||||||
So if one of these values accidentally gets printed, it'll get replaced with `***`.
|
|
||||||
|
|
||||||
This means that a username or port field for example will not get masked.
|
|
||||||
|
|
||||||
## 1Password Connect Configuration
|
|
||||||
|
|
||||||
To use the action, 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 URL and a Connect token, you can set the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` variables.
|
|
||||||
|
|
||||||
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
|
```yml
|
||||||
on: push
|
on: push
|
||||||
@@ -126,27 +54,31 @@ jobs:
|
|||||||
hello-world:
|
hello-world:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- 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
|
- 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:
|
env:
|
||||||
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
SECRET: op://app-cicd/hello-world/secret
|
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"'
|
||||||
|
# Prints: Secret: ***
|
||||||
```
|
```
|
||||||
|
|
||||||
### `configure` Action Inputs
|
## 💙 Community & Support
|
||||||
|
|
||||||
| Name | Default | Environment variable | Description |
|
- 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).
|
||||||
| `connect-host` | | `OP_CONNECT_HOST` | Your 1Password Connect instance URL |
|
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
|
||||||
| `connect-token` | | `OP_CONNECT_TOKEN` | Token to authenticate to your 1Password Connect instance |
|
|
||||||
|
|
||||||
## Supported Runners
|
## 🔐 Security
|
||||||
|
|
||||||
You can run the action on Linux and macOS runners. Windows is currently not supported.
|
1Password requests you practice responsible disclosure if you discover a vulnerability.
|
||||||
|
|
||||||
|
Please file requests by sending an email to bugbounty@agilebits.com.
|
||||||
|
|||||||
16
action.yml
16
action.yml
@@ -7,11 +7,13 @@ branding:
|
|||||||
inputs:
|
inputs:
|
||||||
unset-previous:
|
unset-previous:
|
||||||
description: Whether to unset environment variables populated by 1Password in earlier job steps
|
description: Whether to unset environment variables populated by 1Password in earlier job steps
|
||||||
default: false
|
default: "false"
|
||||||
|
export-env:
|
||||||
|
description: Export the secrets as environment variables
|
||||||
|
default: "false"
|
||||||
|
version:
|
||||||
|
description: Specify which 1Password CLI version to install. Defaults to "latest".
|
||||||
|
default: "latest"
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: "node20"
|
||||||
steps:
|
main: "dist/index.js"
|
||||||
- run: |
|
|
||||||
export INPUT_UNSET_PREVIOUS=${{ inputs.unset-previous }}
|
|
||||||
${{ github.action_path }}/entrypoint.sh
|
|
||||||
shell: bash
|
|
||||||
|
|||||||
4
config/.husky/pre-commit
Executable file
4
config/.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npx lint-staged --config ./config/lint-staged.config.js
|
||||||
4
config/.husky/pre-push
Executable file
4
config/.husky/pre-push
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npm run validate
|
||||||
3
config/.prettierignore
Normal file
3
config/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
28
config/jest.config.js
Normal file
28
config/jest.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const jestConfig = {
|
||||||
|
/**
|
||||||
|
* Jest docs: "We recommend placing the extensions most commonly used in your project
|
||||||
|
* on the left, so if you are using TypeScript, you may want to consider
|
||||||
|
* moving 'ts' to the beginning of the array."
|
||||||
|
*
|
||||||
|
* https://jestjs.io/docs/configuration#modulefileextensions-arraystring
|
||||||
|
*/
|
||||||
|
moduleFileExtensions: ["ts", "js", "json"],
|
||||||
|
rootDir: "../src/",
|
||||||
|
testEnvironment: "node",
|
||||||
|
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
|
||||||
|
transform: {
|
||||||
|
".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;
|
||||||
9
config/lint-staged.config.js
Normal file
9
config/lint-staged.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const lintStagedConfig = {
|
||||||
|
// run formatting and linting on all supported file types
|
||||||
|
"*.{js,json,md,ts,yaml,yml}": "npm run format:write",
|
||||||
|
"*.{js,ts}": ["npm run lint:fix"],
|
||||||
|
// run testing on all supported file types within the src/ directory
|
||||||
|
"src/**/*.{js,ts}": ["npm run test -- --findRelatedTests"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default lintStagedConfig;
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
name: Configure 1Password Connect
|
name: Configure 1Password Connect and service account
|
||||||
description: Persist 1Password Connect host and token for use in next steps.
|
description: Persist 1Password Connect host, token and service account for use in next steps.
|
||||||
author: 1Password
|
author: 1Password
|
||||||
inputs:
|
inputs:
|
||||||
connect-host:
|
connect-host:
|
||||||
description: Your 1Password Connect instance URL
|
description: Your 1Password Connect instance URL
|
||||||
connect-token:
|
connect-token:
|
||||||
description: Token to authenticate to your 1Password Connect instance
|
description: Token to authenticate to your 1Password Connect instance
|
||||||
|
service-account-token:
|
||||||
|
description: Your 1Password service account token
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: "node20"
|
||||||
steps:
|
main: "dist/index.js"
|
||||||
- run: |
|
|
||||||
export INPUT_CONNECT_HOST=${{ inputs.connect-host }}
|
|
||||||
export INPUT_CONNECT_TOKEN=${{ inputs.connect-token }}
|
|
||||||
${{ github.action_path }}/entrypoint.sh
|
|
||||||
shell: bash
|
|
||||||
|
|||||||
27588
configure/dist/index.js
vendored
Normal file
27588
configure/dist/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
configure/dist/package.json
vendored
Normal file
3
configure/dist/package.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
@@ -1,16 +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
|
|
||||||
27
configure/index.js
Normal file
27
configure/index.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
const core = require("@actions/core");
|
||||||
|
|
||||||
|
const configure = () => {
|
||||||
|
const OP_CONNECT_HOST =
|
||||||
|
core.getInput("connect-host", { required: false }) ||
|
||||||
|
process.env.OP_CONNECT_HOST;
|
||||||
|
const OP_CONNECT_TOKEN =
|
||||||
|
core.getInput("connect-token", { required: false }) ||
|
||||||
|
process.env.OP_CONNECT_TOKEN;
|
||||||
|
const OP_SERVICE_ACCOUNT_TOKEN =
|
||||||
|
core.getInput("service-account-token", { required: false }) ||
|
||||||
|
process.env.OP_SERVICE_ACCOUNT_TOKEN;
|
||||||
|
|
||||||
|
if (OP_CONNECT_HOST) {
|
||||||
|
core.exportVariable("OP_CONNECT_HOST", OP_CONNECT_HOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OP_CONNECT_TOKEN) {
|
||||||
|
core.exportVariable("OP_CONNECT_TOKEN", OP_CONNECT_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OP_SERVICE_ACCOUNT_TOKEN) {
|
||||||
|
core.exportVariable("OP_SERVICE_ACCOUNT_TOKEN", OP_SERVICE_ACCOUNT_TOKEN);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
configure();
|
||||||
33709
dist/index.js
vendored
Normal file
33709
dist/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
dist/package.json
vendored
Normal file
3
dist/package.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
```
|
||||||
155
entrypoint.sh
155
entrypoint.sh
@@ -1,155 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# shellcheck disable=SC2046,SC2001,SC2086
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$OP_CONNECT_TOKEN" ] || [ -z "$OP_CONNECT_HOST" ]; then
|
|
||||||
echo "\$OP_CONNECT_TOKEN and \$OP_CONNECT_HOST must be set"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
managed_variables_var="OP_MANAGED_VARIABLES"
|
|
||||||
IFS=',' read -r -a managed_variables <<< "$(printenv $managed_variables_var)"
|
|
||||||
|
|
||||||
# Unset all secrets managed by 1Password if `unset-previous` is set.
|
|
||||||
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
|
|
||||||
|
|
||||||
curl_headers=(-H "Content-Type: application/json" -H "Authorization: Bearer $OP_CONNECT_TOKEN")
|
|
||||||
|
|
||||||
# Iterate over environment varables to find 1Password references, load the secret values,
|
|
||||||
# and make them available as environment variables in the next steps.
|
|
||||||
IFS=$'\n'
|
|
||||||
for possible_ref in $(printenv | grep "=op://" | grep -v "^#"); do
|
|
||||||
env_var=$(echo "$possible_ref" | cut -d '=' -f1)
|
|
||||||
ref=$(printenv $env_var)
|
|
||||||
|
|
||||||
if [[ ! $ref == "op://"* ]]; then
|
|
||||||
echo "Not really a reference: $ref"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
path=$(echo $ref | sed -e "s/^op:\/\///")
|
|
||||||
if [ $(echo "$path" | tr -cd '/' | wc -c) -lt 2 ]; then
|
|
||||||
echo "Expected path to be in format op://<vault>/<item>[/<section>]/<field>: $ref"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Populating variable: $env_var"
|
|
||||||
|
|
||||||
vault=""
|
|
||||||
item=""
|
|
||||||
section=""
|
|
||||||
field=""
|
|
||||||
i=0
|
|
||||||
IFS="/"
|
|
||||||
for component in $path; do
|
|
||||||
((i+=1))
|
|
||||||
case "$i" in
|
|
||||||
1) vault=$component ;;
|
|
||||||
2) item=$component ;;
|
|
||||||
3) section=$component ;;
|
|
||||||
4) field=$component ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
unset IFS
|
|
||||||
|
|
||||||
# If field is not set, it may have wrongfully been interpreted as the section.
|
|
||||||
if [ -z "$field" ]; then
|
|
||||||
field="$section"
|
|
||||||
section=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $(echo -n $(echo $vault | grep "^[a-z0-9]*$") | wc -c) -ne 26 ]]; then
|
|
||||||
echo "Getting vault ID from vault name: $vault"
|
|
||||||
vault=$(curl -sSf "${curl_headers[@]}" "$OP_CONNECT_HOST/v1/vaults?filter=name%20eq%20%22$vault%22" | jq -r '.[0] | .id')
|
|
||||||
if [ -z "$vault" ]; then
|
|
||||||
echo "Could not find vault ID for vault: $vault"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $(echo -n $(echo $item | grep "^[a-z0-9]*$") | wc -c) -ne 26 ]]; then
|
|
||||||
echo "Getting item ID from vault $vault..."
|
|
||||||
item=$(curl -sSf "${curl_headers[@]}" "$OP_CONNECT_HOST/v1/vaults/$vault/items?filter=title%20eq%20%22$item%22" | jq -r '.[0] | .id')
|
|
||||||
if [ -z "$item" ]; then
|
|
||||||
echo "Could not find item ID for item: $item"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Loading item $item from vault $vault..."
|
|
||||||
item_json=$(curl -sSf "${curl_headers[@]}" "$OP_CONNECT_HOST/v1/vaults/$vault/items/$item")
|
|
||||||
|
|
||||||
jq_field_selector=".id == \"$field\" or .label == \"$field\""
|
|
||||||
jq_section_selector=".section == null"
|
|
||||||
|
|
||||||
# If the reference contains a section, edit the jq selector to take that into account.
|
|
||||||
if [ -n "$section" ]; then
|
|
||||||
echo "Looking for section: $section"
|
|
||||||
section_id=$(echo "$item_json" | jq -r ".sections[] | select(.id == \"$section\" or .label == \"$section\") | .id")
|
|
||||||
jq_section_selector=".section.id == \"$section_id\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
jq_secret_selector="$jq_section_selector and ($jq_field_selector)"
|
|
||||||
|
|
||||||
echo "Looking for field: $field"
|
|
||||||
secret_field_json=$(echo "$item_json" | jq -r "first(.fields[] | select($jq_secret_selector))")
|
|
||||||
|
|
||||||
field_type=$(echo "$secret_field_json" | jq -r '.type')
|
|
||||||
field_purpose=$(echo "$secret_field_json" | jq -r '.purpose')
|
|
||||||
secret_value=$(echo "$secret_field_json" | jq -r '.value')
|
|
||||||
|
|
||||||
if [ -z "$secret_value" ]; then
|
|
||||||
echo "Could not find or access secret $ref"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If the field is marked as concealed or is a note, register a mask
|
|
||||||
# for the secret to prevent accidental log exposure.
|
|
||||||
if [ "$field_type" == "CONCEALED" ] || [ "$field_purpose" == "NOTES" ]; then
|
|
||||||
# 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
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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 16)
|
|
||||||
|
|
||||||
{
|
|
||||||
# Populate env var, using heredoc syntax with generated identifier
|
|
||||||
echo "$env_var<<${random_heredoc_identifier}"
|
|
||||||
echo "$secret_value"
|
|
||||||
echo "${random_heredoc_identifier}"
|
|
||||||
} >> $GITHUB_ENV
|
|
||||||
|
|
||||||
managed_variables+=("$env_var")
|
|
||||||
done
|
|
||||||
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
|
|
||||||
8239
package-lock.json
generated
Normal file
8239
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
package.json
Normal file
71
package.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "load-secrets-action",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"description": "Load Secrets from 1Password",
|
||||||
|
"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 ./",
|
||||||
|
"lint": "eslint ./",
|
||||||
|
"lint:fix": "npm run lint -- --fix",
|
||||||
|
"prepare": "husky install ./config/.husky",
|
||||||
|
"test": "jest --config=./config/jest.config.js",
|
||||||
|
"test:clearcache": "jest --clearCache",
|
||||||
|
"test:coverage": "npm run test -- --coverage",
|
||||||
|
"test:watch": "npm run test -- --watch",
|
||||||
|
"typecheck": "tsc",
|
||||||
|
"validate": "npm run format:check && npm run lint && npm run test:coverage && npm run typecheck && npm run build"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/1Password/load-secrets-action.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"actions",
|
||||||
|
"1password",
|
||||||
|
"load secrets",
|
||||||
|
"connect"
|
||||||
|
],
|
||||||
|
"author": "1Password",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/1Password/load-secrets-action/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"@1password/op-js": "^0.1.11",
|
||||||
|
"@actions/core": "^1.10.1",
|
||||||
|
"@actions/exec": "^1.1.1",
|
||||||
|
"@actions/tool-cache": "^2.0.2",
|
||||||
|
"dotenv": "^17.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@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": "@1password/eslint-config",
|
||||||
|
"ignorePatterns": [
|
||||||
|
"coverage/"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"project": "./tsconfig.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prettier": "@1password/prettier-config"
|
||||||
|
}
|
||||||
7
src/constants.ts
Normal file
7
src/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const envConnectHost = "OP_CONNECT_HOST";
|
||||||
|
export const envConnectToken = "OP_CONNECT_TOKEN";
|
||||||
|
export const envServiceAccountToken = "OP_SERVICE_ACCOUNT_TOKEN";
|
||||||
|
export const envManagedVariables = "OP_MANAGED_VARIABLES";
|
||||||
|
export const envFilePath = "OP_ENV_FILE";
|
||||||
|
|
||||||
|
export const authErr = `Authentication error with environment variables: you must set either 1) ${envServiceAccountToken}, or 2) both ${envConnectHost} and ${envConnectToken}.`;
|
||||||
60
src/index.ts
Normal file
60
src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import { validateCli } from "@1password/op-js";
|
||||||
|
import { installCliOnGithubActionRunner } from "./op-cli-installer";
|
||||||
|
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
|
||||||
|
import { envFilePath } from "./constants";
|
||||||
|
|
||||||
|
const loadSecretsAction = async () => {
|
||||||
|
try {
|
||||||
|
// Get action inputs
|
||||||
|
const shouldUnsetPrevious = core.getBooleanInput("unset-previous");
|
||||||
|
const shouldExportEnv = core.getBooleanInput("export-env");
|
||||||
|
|
||||||
|
// Unset all secrets managed by 1Password if `unset-previous` is set.
|
||||||
|
if (shouldUnsetPrevious) {
|
||||||
|
unsetPrevious();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that a proper authentication configuration is set for the CLI
|
||||||
|
validateAuth();
|
||||||
|
|
||||||
|
// Set environment variables from OP_ENV_FILE
|
||||||
|
const file = process.env[envFilePath];
|
||||||
|
if (file) {
|
||||||
|
core.info(`Loading environment variables from file: ${file}`);
|
||||||
|
dotenv.config({ path: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download and install the CLI
|
||||||
|
await installCLI();
|
||||||
|
|
||||||
|
// Load secrets
|
||||||
|
await loadSecrets(shouldExportEnv);
|
||||||
|
} catch (error) {
|
||||||
|
// It's possible for the Error constructor to be modified to be anything
|
||||||
|
// in JavaScript, so the following code accounts for this possibility.
|
||||||
|
// https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript
|
||||||
|
let message = "Unknown Error";
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
} else {
|
||||||
|
String(error);
|
||||||
|
}
|
||||||
|
core.setFailed(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// This function's name is an exception from the naming convention
|
||||||
|
// since we refer to the 1Password CLI here.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
const installCLI = async (): Promise<void> => {
|
||||||
|
// validateCli checks if there's an existing 1Password CLI installed on the runner.
|
||||||
|
// If there's no CLI installed, then validateCli will throw an error, which we will use
|
||||||
|
// as an indicator that we need to execute the installation script.
|
||||||
|
await validateCli().catch(async () => {
|
||||||
|
await installCliOnGithubActionRunner();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSecretsAction();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import os from "os";
|
||||||
|
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as tc from "@actions/tool-cache";
|
||||||
|
|
||||||
|
export type SupportedPlatform = Extract<
|
||||||
|
NodeJS.Platform,
|
||||||
|
"linux" | "darwin" | "win32"
|
||||||
|
>;
|
||||||
|
|
||||||
|
// maps OS architecture names to 1Password CLI installer architecture names
|
||||||
|
export const archMap: Record<string, string> = {
|
||||||
|
ia32: "386",
|
||||||
|
x64: "amd64",
|
||||||
|
arm: "arm",
|
||||||
|
arm64: "arm64",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Builds the download URL for the 1Password CLI based on the platform and version.
|
||||||
|
export const cliUrlBuilder: Record<
|
||||||
|
SupportedPlatform,
|
||||||
|
(version: string, arch?: string) => string
|
||||||
|
> = {
|
||||||
|
linux: (version, arch) =>
|
||||||
|
`https://cache.agilebits.com/dist/1P/op2/pkg/${version}/op_linux_${arch}_${version}.zip`,
|
||||||
|
darwin: (version) =>
|
||||||
|
`https://cache.agilebits.com/dist/1P/op2/pkg/${version}/op_apple_universal_${version}.pkg`,
|
||||||
|
win32: (version, arch) =>
|
||||||
|
`https://cache.agilebits.com/dist/1P/op2/pkg/${version}/op_windows_${arch}_${version}.zip`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CliInstaller {
|
||||||
|
public readonly version: string;
|
||||||
|
public readonly arch: string;
|
||||||
|
|
||||||
|
public constructor(version: string) {
|
||||||
|
this.version = version;
|
||||||
|
this.arch = this.getArch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async install(url: string): Promise<void> {
|
||||||
|
console.info(`Downloading 1Password CLI from: ${url}`);
|
||||||
|
const downloadPath = await tc.downloadTool(url);
|
||||||
|
console.info("Installing 1Password CLI");
|
||||||
|
const extractedPath = await tc.extractZip(downloadPath);
|
||||||
|
core.addPath(extractedPath);
|
||||||
|
core.info("1Password CLI installed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private getArch(): string {
|
||||||
|
const arch = archMap[os.arch()];
|
||||||
|
if (!arch) {
|
||||||
|
throw new Error("Unsupported architecture");
|
||||||
|
}
|
||||||
|
|
||||||
|
return arch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { type Installer, newCliInstaller } from "./installer";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import os from "os";
|
||||||
|
|
||||||
|
import { newCliInstaller } from "./installer";
|
||||||
|
import { LinuxInstaller } from "./linux";
|
||||||
|
import { MacOsInstaller } from "./macos";
|
||||||
|
import { WindowsInstaller } from "./windows";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("newCliInstaller", () => {
|
||||||
|
const version = "1.0.0";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return LinuxInstaller for linux platform", () => {
|
||||||
|
jest.spyOn(os, "platform").mockReturnValue("linux");
|
||||||
|
const installer = newCliInstaller(version);
|
||||||
|
expect(installer).toBeInstanceOf(LinuxInstaller);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return MacOsInstaller for darwin platform", () => {
|
||||||
|
jest.spyOn(os, "platform").mockReturnValue("darwin");
|
||||||
|
const installer = newCliInstaller(version);
|
||||||
|
expect(installer).toBeInstanceOf(MacOsInstaller);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return WindowsInstaller for win32 platform", () => {
|
||||||
|
jest.spyOn(os, "platform").mockReturnValue("win32");
|
||||||
|
const installer = newCliInstaller(version);
|
||||||
|
expect(installer).toBeInstanceOf(WindowsInstaller);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for unsupported platform", () => {
|
||||||
|
jest.spyOn(os, "platform").mockReturnValue("sunos");
|
||||||
|
expect(() => newCliInstaller(version)).toThrow(
|
||||||
|
"Unsupported platform: sunos",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import os from "os";
|
||||||
|
|
||||||
|
import { LinuxInstaller } from "./linux";
|
||||||
|
import { MacOsInstaller } from "./macos";
|
||||||
|
import { WindowsInstaller } from "./windows";
|
||||||
|
|
||||||
|
export interface Installer {
|
||||||
|
installCli(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const newCliInstaller = (version: string): Installer => {
|
||||||
|
const platform = os.platform();
|
||||||
|
switch (platform) {
|
||||||
|
case "linux":
|
||||||
|
return new LinuxInstaller(version);
|
||||||
|
case "darwin":
|
||||||
|
return new MacOsInstaller(version);
|
||||||
|
case "win32":
|
||||||
|
return new WindowsInstaller(version);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported platform: ${platform}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import os from "os";
|
||||||
|
|
||||||
|
import {
|
||||||
|
archMap,
|
||||||
|
CliInstaller,
|
||||||
|
cliUrlBuilder,
|
||||||
|
type SupportedPlatform,
|
||||||
|
} from "./cli-installer";
|
||||||
|
import { LinuxInstaller } from "./linux";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LinuxInstaller", () => {
|
||||||
|
const version = "1.2.3";
|
||||||
|
const arch: NodeJS.Architecture = "arm64";
|
||||||
|
|
||||||
|
it("should construct with given version and architecture", () => {
|
||||||
|
jest.spyOn(os, "arch").mockReturnValue(arch);
|
||||||
|
const installer = new LinuxInstaller(version);
|
||||||
|
expect(installer.version).toEqual(version);
|
||||||
|
expect(installer.arch).toEqual(archMap[arch]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call install with correct URL", async () => {
|
||||||
|
const installer = new LinuxInstaller(version);
|
||||||
|
const installMock = jest
|
||||||
|
.spyOn(CliInstaller.prototype, "install")
|
||||||
|
.mockResolvedValue();
|
||||||
|
|
||||||
|
await installer.installCli();
|
||||||
|
|
||||||
|
const builder = cliUrlBuilder["linux" as SupportedPlatform];
|
||||||
|
const url = builder(version, installer.arch);
|
||||||
|
expect(installMock).toHaveBeenCalledWith(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/op-cli-installer/github-action/cli-installer/linux.ts
Normal file
19
src/op-cli-installer/github-action/cli-installer/linux.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
CliInstaller,
|
||||||
|
cliUrlBuilder,
|
||||||
|
type SupportedPlatform,
|
||||||
|
} from "./cli-installer";
|
||||||
|
import type { Installer } from "./installer";
|
||||||
|
|
||||||
|
export class LinuxInstaller extends CliInstaller implements Installer {
|
||||||
|
private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux
|
||||||
|
|
||||||
|
public constructor(version: string) {
|
||||||
|
super(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async installCli(): Promise<void> {
|
||||||
|
const urlBuilder = cliUrlBuilder[this.platform];
|
||||||
|
await super.install(urlBuilder(this.version, this.arch));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import os from "os";
|
||||||
|
|
||||||
|
import {
|
||||||
|
archMap,
|
||||||
|
cliUrlBuilder,
|
||||||
|
type SupportedPlatform,
|
||||||
|
} from "./cli-installer";
|
||||||
|
import { MacOsInstaller } from "./macos";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MacOsInstaller", () => {
|
||||||
|
const version = "1.2.3";
|
||||||
|
const arch: NodeJS.Architecture = "x64";
|
||||||
|
|
||||||
|
it("should construct with given version and architecture", () => {
|
||||||
|
jest.spyOn(os, "arch").mockReturnValue(arch);
|
||||||
|
const installer = new MacOsInstaller(version);
|
||||||
|
expect(installer.version).toEqual(version);
|
||||||
|
expect(installer.arch).toEqual(archMap[arch]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call install with correct URL", async () => {
|
||||||
|
const installer = new MacOsInstaller(version);
|
||||||
|
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
|
||||||
|
|
||||||
|
await installer.installCli();
|
||||||
|
|
||||||
|
const builder = cliUrlBuilder["darwin" as SupportedPlatform];
|
||||||
|
const url = builder(version, installer.arch);
|
||||||
|
expect(installMock).toHaveBeenCalledWith(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/op-cli-installer/github-action/cli-installer/macos.ts
Normal file
49
src/op-cli-installer/github-action/cli-installer/macos.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { execFile } from "child_process";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as tc from "@actions/tool-cache";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CliInstaller,
|
||||||
|
cliUrlBuilder,
|
||||||
|
type SupportedPlatform,
|
||||||
|
} from "./cli-installer";
|
||||||
|
import { type Installer } from "./installer";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export class MacOsInstaller extends CliInstaller implements Installer {
|
||||||
|
private readonly platform: SupportedPlatform = "darwin"; // Node.js platform identifier for macOS
|
||||||
|
|
||||||
|
public constructor(version: string) {
|
||||||
|
super(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async installCli(): Promise<void> {
|
||||||
|
const urlBuilder = cliUrlBuilder[this.platform];
|
||||||
|
await this.install(urlBuilder(this.version));
|
||||||
|
}
|
||||||
|
|
||||||
|
// @actions/tool-cache package does not support .pkg files, so we need to handle the installation manually
|
||||||
|
public override async install(downloadUrl: string): Promise<void> {
|
||||||
|
console.info(`Downloading 1Password CLI from: ${downloadUrl}`);
|
||||||
|
const pkgPath = await tc.downloadTool(downloadUrl);
|
||||||
|
const pkgWithExtension = `${pkgPath}.pkg`;
|
||||||
|
fs.renameSync(pkgPath, pkgWithExtension);
|
||||||
|
|
||||||
|
const expandDir = "temp-pkg";
|
||||||
|
await execFileAsync("pkgutil", ["--expand", pkgWithExtension, expandDir]);
|
||||||
|
const payloadPath = path.join(expandDir, "op.pkg", "Payload");
|
||||||
|
console.info("Installing 1Password CLI");
|
||||||
|
const cliPath = await tc.extractTar(payloadPath);
|
||||||
|
core.addPath(cliPath);
|
||||||
|
|
||||||
|
fs.rmSync(expandDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(pkgPath, { force: true });
|
||||||
|
|
||||||
|
core.info("1Password CLI installed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import os from "os";
|
||||||
|
|
||||||
|
import {
|
||||||
|
archMap,
|
||||||
|
CliInstaller,
|
||||||
|
cliUrlBuilder,
|
||||||
|
type SupportedPlatform,
|
||||||
|
} from "./cli-installer";
|
||||||
|
import { WindowsInstaller } from "./windows";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WindowsInstaller", () => {
|
||||||
|
const version = "1.2.3";
|
||||||
|
const arch: NodeJS.Architecture = "x64";
|
||||||
|
|
||||||
|
it("should construct with given version and architecture", () => {
|
||||||
|
jest.spyOn(os, "arch").mockReturnValue(arch);
|
||||||
|
const installer = new WindowsInstaller(version);
|
||||||
|
expect(installer.version).toEqual(version);
|
||||||
|
expect(installer.arch).toEqual(archMap[arch]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call install with correct URL", async () => {
|
||||||
|
const installer = new WindowsInstaller(version);
|
||||||
|
const installMock = jest
|
||||||
|
.spyOn(CliInstaller.prototype, "install")
|
||||||
|
.mockResolvedValue();
|
||||||
|
|
||||||
|
await installer.installCli();
|
||||||
|
|
||||||
|
const builder = cliUrlBuilder["win32" as SupportedPlatform];
|
||||||
|
const url = builder(version, installer.arch);
|
||||||
|
expect(installMock).toHaveBeenCalledWith(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/op-cli-installer/github-action/cli-installer/windows.ts
Normal file
19
src/op-cli-installer/github-action/cli-installer/windows.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
CliInstaller,
|
||||||
|
cliUrlBuilder,
|
||||||
|
type SupportedPlatform,
|
||||||
|
} from "./cli-installer";
|
||||||
|
import type { Installer } from "./installer";
|
||||||
|
|
||||||
|
export class WindowsInstaller extends CliInstaller implements Installer {
|
||||||
|
private readonly platform: SupportedPlatform = "win32"; // Node.js platform identifier for Windows
|
||||||
|
|
||||||
|
public constructor(version: string) {
|
||||||
|
super(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async installCli(): Promise<void> {
|
||||||
|
const urlBuilder = cliUrlBuilder[this.platform];
|
||||||
|
await super.install(urlBuilder(this.version, this.arch));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/op-cli-installer/github-action/index.ts
Normal file
18
src/op-cli-installer/github-action/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
|
||||||
|
import { ReleaseChannel, VersionResolver } from "../version";
|
||||||
|
|
||||||
|
import { newCliInstaller } from "./cli-installer";
|
||||||
|
|
||||||
|
// Installs the 1Password CLI on a GitHub Action runner.
|
||||||
|
export const installCliOnGithubActionRunner = async (
|
||||||
|
version?: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Get the version from parameter, if not passed - from the job input. Defaults to latest if no version is provided
|
||||||
|
const providedVersion =
|
||||||
|
version || core.getInput("version") || ReleaseChannel.latest;
|
||||||
|
const versionResolver = new VersionResolver(providedVersion);
|
||||||
|
await versionResolver.resolve();
|
||||||
|
const installer = newCliInstaller(versionResolver.get());
|
||||||
|
await installer.installCli();
|
||||||
|
};
|
||||||
81
src/op-cli-installer/index.test.ts
Normal file
81
src/op-cli-installer/index.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
|
||||||
|
import { newCliInstaller } from "./github-action/cli-installer";
|
||||||
|
import {
|
||||||
|
installCliOnGithubActionRunner,
|
||||||
|
ReleaseChannel,
|
||||||
|
VersionResolver,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
|
jest.mock("./github-action/cli-installer", () => ({
|
||||||
|
newCliInstaller: jest.fn().mockImplementation((_resolved: string) => ({
|
||||||
|
installCli: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installCliOnGithubActionRunner", () => {
|
||||||
|
it("should defaults to `latest` when nothing is passed", async () => {
|
||||||
|
jest.spyOn(core, "getInput").mockReturnValue("");
|
||||||
|
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||||
|
jest
|
||||||
|
.spyOn(VersionResolver.prototype, "get")
|
||||||
|
.mockReturnValue(ReleaseChannel.latest);
|
||||||
|
|
||||||
|
await installCliOnGithubActionRunner();
|
||||||
|
|
||||||
|
expect(newCliInstaller).toHaveBeenCalledWith(ReleaseChannel.latest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should defaults to `latest` when undefined is passed", async () => {
|
||||||
|
jest.spyOn(core, "getInput").mockReturnValue("");
|
||||||
|
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||||
|
jest
|
||||||
|
.spyOn(VersionResolver.prototype, "get")
|
||||||
|
.mockReturnValue(ReleaseChannel.latest);
|
||||||
|
|
||||||
|
await installCliOnGithubActionRunner(undefined);
|
||||||
|
|
||||||
|
expect(newCliInstaller).toHaveBeenCalledWith(ReleaseChannel.latest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set provided explicit version", async () => {
|
||||||
|
const providedVersion = "1.2.3";
|
||||||
|
jest.spyOn(core, "getInput").mockReturnValue("");
|
||||||
|
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||||
|
jest
|
||||||
|
.spyOn(VersionResolver.prototype, "get")
|
||||||
|
.mockReturnValue(providedVersion);
|
||||||
|
|
||||||
|
await installCliOnGithubActionRunner(providedVersion);
|
||||||
|
|
||||||
|
expect(newCliInstaller).toHaveBeenCalledWith(providedVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set version provided as job input", async () => {
|
||||||
|
const providedVersion = "3.0.0";
|
||||||
|
jest.spyOn(core, "getInput").mockReturnValue(providedVersion);
|
||||||
|
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||||
|
jest
|
||||||
|
.spyOn(VersionResolver.prototype, "get")
|
||||||
|
.mockReturnValue(providedVersion);
|
||||||
|
|
||||||
|
await installCliOnGithubActionRunner();
|
||||||
|
|
||||||
|
expect(newCliInstaller).toHaveBeenCalledWith(providedVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for invalid version", async () => {
|
||||||
|
const providedVersion = "invalid";
|
||||||
|
jest.spyOn(core, "getInput").mockReturnValue(providedVersion);
|
||||||
|
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
|
||||||
|
jest
|
||||||
|
.spyOn(VersionResolver.prototype, "get")
|
||||||
|
.mockReturnValue(providedVersion);
|
||||||
|
|
||||||
|
await expect(installCliOnGithubActionRunner()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
2
src/op-cli-installer/index.ts
Normal file
2
src/op-cli-installer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { installCliOnGithubActionRunner } from "./github-action";
|
||||||
|
export { ReleaseChannel, VersionResolver } from "./version";
|
||||||
13
src/op-cli-installer/version/constants.ts
Normal file
13
src/op-cli-installer/version/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export enum ReleaseChannel {
|
||||||
|
latest = "latest",
|
||||||
|
latestBeta = "latest-beta",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionResponse {
|
||||||
|
// eslint disabled next line as CLI2 is expected in getting CLI versions response
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/naming-convention */
|
||||||
|
CLI2: {
|
||||||
|
release: { version: string };
|
||||||
|
beta: { version: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
91
src/op-cli-installer/version/helper.test.ts
Normal file
91
src/op-cli-installer/version/helper.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { ReleaseChannel } from "./constants";
|
||||||
|
import { getLatestVersion } from "./helper";
|
||||||
|
|
||||||
|
describe("getLatestVersion", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return latest stable version", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CLI2: {
|
||||||
|
release: { version: "2.31.0" },
|
||||||
|
beta: { version: "2.32.0-beta.01" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
|
json: async () => mockResponse,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const version = await getLatestVersion(ReleaseChannel.latest);
|
||||||
|
expect(version).toBe("2.31.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return latest beta version", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CLI2: {
|
||||||
|
release: { version: "2.31.0" },
|
||||||
|
beta: { version: "2.32.0-beta.01" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
|
json: async () => mockResponse,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const version = await getLatestVersion(ReleaseChannel.latestBeta);
|
||||||
|
expect(version).toBe("2.32.0-beta.01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if no CLI2 field", async () => {
|
||||||
|
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(getLatestVersion(ReleaseChannel.latest)).rejects.toThrow(
|
||||||
|
`No ${ReleaseChannel.latest} versions found`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if no stable version found", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CLI2: {
|
||||||
|
beta: { version: "2.32.0-beta.01" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
|
json: async () => mockResponse,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(getLatestVersion(ReleaseChannel.latest)).rejects.toThrow(
|
||||||
|
`No ${ReleaseChannel.latest} versions found`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if no beta version found", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CLI2: {
|
||||||
|
release: { version: "2.32.0" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(global, "fetch").mockResolvedValueOnce({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
|
json: async () => mockResponse,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(getLatestVersion(ReleaseChannel.latestBeta)).rejects.toThrow(
|
||||||
|
`No ${ReleaseChannel.latestBeta} versions found`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
src/op-cli-installer/version/helper.ts
Normal file
23
src/op-cli-installer/version/helper.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
|
||||||
|
import { ReleaseChannel, type VersionResponse } from "./constants";
|
||||||
|
|
||||||
|
// Returns the latest version of the 1Password CLI based on the specified channel.
|
||||||
|
export const getLatestVersion = async (
|
||||||
|
channel: ReleaseChannel,
|
||||||
|
): Promise<string> => {
|
||||||
|
core.info(`Getting ${channel} version number`);
|
||||||
|
const res = await fetch("https://app-updates.agilebits.com/latest");
|
||||||
|
const json = (await res.json()) as VersionResponse;
|
||||||
|
const latestStable = json?.CLI2?.release?.version;
|
||||||
|
const latestBeta = json?.CLI2?.beta?.version;
|
||||||
|
const version =
|
||||||
|
channel === ReleaseChannel.latestBeta ? latestBeta : latestStable;
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
core.error(`No ${channel} versions found`);
|
||||||
|
throw new Error(`No ${channel} versions found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return version;
|
||||||
|
};
|
||||||
2
src/op-cli-installer/version/index.ts
Normal file
2
src/op-cli-installer/version/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { VersionResolver } from "./version-resolver";
|
||||||
|
export { ReleaseChannel } from "./constants";
|
||||||
45
src/op-cli-installer/version/validate.test.ts
Normal file
45
src/op-cli-installer/version/validate.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "@jest/globals";
|
||||||
|
|
||||||
|
import { validateVersion } from "./validate";
|
||||||
|
|
||||||
|
describe("validateVersion", () => {
|
||||||
|
it('should not throw for "latest"', () => {
|
||||||
|
expect(() => validateVersion("latest")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw for "latest-beta"', () => {
|
||||||
|
expect(() => validateVersion("latest-beta")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw for valid semver version "2.18.0"', () => {
|
||||||
|
expect(() => validateVersion("2.18.0")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for partial version "2"', () => {
|
||||||
|
expect(() => validateVersion("2")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for partial version "2.1"', () => {
|
||||||
|
expect(() => validateVersion("2.1")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw for valid beta "2.19.0-beta.01"', () => {
|
||||||
|
expect(() => validateVersion("2.19.0-beta.01")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw for valid beta "2.19.3-beta.12"', () => {
|
||||||
|
expect(() => validateVersion("2.19.3-beta.12")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw for coerced version "v2.19.0"', () => {
|
||||||
|
expect(() => validateVersion("v2.19.0")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for invalid version "latest-abc"', () => {
|
||||||
|
expect(() => validateVersion("latest-abc")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for empty string", () => {
|
||||||
|
expect(() => validateVersion("")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
src/op-cli-installer/version/validate.ts
Normal file
23
src/op-cli-installer/version/validate.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import semver from "semver";
|
||||||
|
|
||||||
|
import { ReleaseChannel } from "./constants";
|
||||||
|
|
||||||
|
// Validates if the provided version type is a valid enum value or a valid semver version.
|
||||||
|
export const validateVersion = (input: string): void => {
|
||||||
|
if (Object.values(ReleaseChannel).includes(input as ReleaseChannel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1Password beta releases (aka 2.19.0-beta.01) are not semver compliant.
|
||||||
|
// According to semver, it should be "2.19.0-beta.1".
|
||||||
|
// That's why we need to normalize them before validating.
|
||||||
|
// Accepts valid semver versions like "2.18.0" or beta-releases like "2.19.0-beta.01"
|
||||||
|
// or versions with 'v' prefix like "v2.19.0"
|
||||||
|
const normalized = input.replace(/-beta\.0*(\d+)/, "-beta.$1");
|
||||||
|
const normInput = new semver.SemVer(normalized);
|
||||||
|
if (semver.valid(normInput)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid version input: ${input}`);
|
||||||
|
};
|
||||||
58
src/op-cli-installer/version/version-resolver.test.ts
Normal file
58
src/op-cli-installer/version/version-resolver.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { expect } from "@jest/globals";
|
||||||
|
|
||||||
|
import { ReleaseChannel } from "./constants";
|
||||||
|
import { VersionResolver } from "./version-resolver";
|
||||||
|
|
||||||
|
describe("VersionResolver", () => {
|
||||||
|
test("should throw error when invalid version provided", () => {
|
||||||
|
expect(() => new VersionResolver("vv")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw error when version is empty", () => {
|
||||||
|
expect(() => new VersionResolver("")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw error for major version only", () => {
|
||||||
|
expect(() => new VersionResolver("1")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw error for major and minor version only", () => {
|
||||||
|
expect(() => new VersionResolver("1.0")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve latest stable version", async () => {
|
||||||
|
const versionResolver = new VersionResolver(ReleaseChannel.latest);
|
||||||
|
await versionResolver.resolve();
|
||||||
|
expect(versionResolver.get()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve latest beta version", async () => {
|
||||||
|
const versionResolver = new VersionResolver(ReleaseChannel.latestBeta);
|
||||||
|
await versionResolver.resolve();
|
||||||
|
expect(versionResolver.get()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve version without 'v' prefix", async () => {
|
||||||
|
const versionResolver = new VersionResolver("1.0.0");
|
||||||
|
await versionResolver.resolve();
|
||||||
|
expect(versionResolver.get()).toBe("v1.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve version with 'v' prefix", async () => {
|
||||||
|
const versionResolver = new VersionResolver("v1.0.0");
|
||||||
|
await versionResolver.resolve();
|
||||||
|
expect(versionResolver.get()).toBe("v1.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve beta version without 'v' prefix", async () => {
|
||||||
|
const versionResolver = new VersionResolver("2.19.0-beta.01");
|
||||||
|
await versionResolver.resolve();
|
||||||
|
expect(versionResolver.get()).toBe("v2.19.0-beta.01");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve beta version with 'v' prefix", async () => {
|
||||||
|
const versionResolver = new VersionResolver("v2.19.0-beta.01");
|
||||||
|
await versionResolver.resolve();
|
||||||
|
expect(versionResolver.get()).toBe("v2.19.0-beta.01");
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/op-cli-installer/version/version-resolver.ts
Normal file
45
src/op-cli-installer/version/version-resolver.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
|
||||||
|
import { ReleaseChannel } from "./constants";
|
||||||
|
import { getLatestVersion } from "./helper";
|
||||||
|
import { validateVersion } from "./validate";
|
||||||
|
|
||||||
|
export class VersionResolver {
|
||||||
|
private version: string;
|
||||||
|
|
||||||
|
public constructor(version: string) {
|
||||||
|
this.validate(version);
|
||||||
|
this.version = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(): string {
|
||||||
|
return this.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolve(): Promise<void> {
|
||||||
|
core.info(`Resolving version: ${this.version}`);
|
||||||
|
if (!this.version) {
|
||||||
|
core.error("Version is not provided");
|
||||||
|
throw new Error("Version is not provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isReleaseChannel(this.version)) {
|
||||||
|
this.version = await getLatestVersion(this.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add `v` prefix if not already present
|
||||||
|
this.version = this.version.startsWith("v")
|
||||||
|
? this.version
|
||||||
|
: `v${this.version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validate(version: string) {
|
||||||
|
core.info(`Validating version number: '${version}'`);
|
||||||
|
validateVersion(version);
|
||||||
|
core.info(`Version number '${version}' is valid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isReleaseChannel(value: string): value is ReleaseChannel {
|
||||||
|
return Object.values(ReleaseChannel).includes(value as ReleaseChannel);
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/utils.test.ts
Normal file
164
src/utils.test.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as exec from "@actions/exec";
|
||||||
|
import { read, setClientInfo } from "@1password/op-js";
|
||||||
|
import {
|
||||||
|
extractSecret,
|
||||||
|
loadSecrets,
|
||||||
|
unsetPrevious,
|
||||||
|
validateAuth,
|
||||||
|
} from "./utils";
|
||||||
|
import {
|
||||||
|
authErr,
|
||||||
|
envConnectHost,
|
||||||
|
envConnectToken,
|
||||||
|
envManagedVariables,
|
||||||
|
envServiceAccountToken,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
jest.mock("@actions/core");
|
||||||
|
jest.mock("@actions/exec", () => ({
|
||||||
|
getExecOutput: jest.fn(() => ({
|
||||||
|
stdout: "MOCK_SECRET",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
jest.mock("@1password/op-js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateAuth", () => {
|
||||||
|
const testConnectHost = "https://localhost:8000";
|
||||||
|
const testConnectToken = "token";
|
||||||
|
const testServiceAccountToken = "ops_token";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env[envConnectHost] = "";
|
||||||
|
process.env[envConnectToken] = "";
|
||||||
|
process.env[envServiceAccountToken] = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error when no config is provided", () => {
|
||||||
|
expect(validateAuth).toThrow(authErr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error when partial Connect config is provided", () => {
|
||||||
|
process.env[envConnectHost] = testConnectHost;
|
||||||
|
expect(validateAuth).toThrow(authErr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be authenticated as a Connect client", () => {
|
||||||
|
process.env[envConnectHost] = testConnectHost;
|
||||||
|
process.env[envConnectToken] = testConnectToken;
|
||||||
|
expect(validateAuth).not.toThrow(authErr);
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be authenticated as a service account", () => {
|
||||||
|
process.env[envServiceAccountToken] = testServiceAccountToken;
|
||||||
|
expect(validateAuth).not.toThrow(authErr);
|
||||||
|
expect(core.info).toHaveBeenCalledWith(
|
||||||
|
"Authenticated with Service account.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize Connect over service account if both are configured", () => {
|
||||||
|
process.env[envServiceAccountToken] = testServiceAccountToken;
|
||||||
|
process.env[envConnectHost] = testConnectHost;
|
||||||
|
process.env[envConnectToken] = testConnectToken;
|
||||||
|
expect(validateAuth).not.toThrow(authErr);
|
||||||
|
expect(core.warning).toHaveBeenCalled();
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractSecret", () => {
|
||||||
|
const envTestSecretEnv = "TEST_SECRET";
|
||||||
|
const testSecretRef = "op://vault/item/secret";
|
||||||
|
const testSecretValue = "Secret1@3$";
|
||||||
|
|
||||||
|
read.parse = jest.fn().mockReturnValue(testSecretValue);
|
||||||
|
|
||||||
|
process.env[envTestSecretEnv] = testSecretRef;
|
||||||
|
|
||||||
|
it("should set secret as step output", () => {
|
||||||
|
extractSecret(envTestSecretEnv, false);
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set secret as environment variable", () => {
|
||||||
|
extractSecret(envTestSecretEnv, true);
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setOutput).not.toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadSecrets", () => {
|
||||||
|
it("sets the client info and gets the executed output", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(setClientInfo).toHaveBeenCalledWith({
|
||||||
|
name: "1Password GitHub Action",
|
||||||
|
id: "GHA",
|
||||||
|
});
|
||||||
|
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
"OP_MANAGED_VARIABLES",
|
||||||
|
"MOCK_SECRET",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("return early if no env vars with secrets found", async () => {
|
||||||
|
(exec.getExecOutput as jest.Mock).mockReturnValueOnce({ stdout: "" });
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("core.exportVariable", () => {
|
||||||
|
it("is called when shouldExportEnv is true", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not called when shouldExportEnv is false", async () => {
|
||||||
|
await loadSecrets(false);
|
||||||
|
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unsetPrevious", () => {
|
||||||
|
const testManagedEnv = "TEST_SECRET";
|
||||||
|
const testSecretValue = "MyS3cr#T";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env[testManagedEnv] = testSecretValue;
|
||||||
|
process.env[envManagedVariables] = testManagedEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unset the environment variable if user wants it", () => {
|
||||||
|
unsetPrevious();
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Unsetting previous values ...");
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Unsetting TEST_SECRET");
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/utils.ts
Normal file
91
src/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as exec from "@actions/exec";
|
||||||
|
import { read, setClientInfo, semverToInt } from "@1password/op-js";
|
||||||
|
import { version } from "../package.json";
|
||||||
|
import {
|
||||||
|
authErr,
|
||||||
|
envConnectHost,
|
||||||
|
envConnectToken,
|
||||||
|
envServiceAccountToken,
|
||||||
|
envManagedVariables,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
export const validateAuth = (): void => {
|
||||||
|
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
|
||||||
|
const isServiceAccount = process.env[envServiceAccountToken];
|
||||||
|
|
||||||
|
if (isConnect && isServiceAccount) {
|
||||||
|
core.warning(
|
||||||
|
"WARNING: Both service account and Connect credentials are provided. Connect credentials will take priority.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnect && !isServiceAccount) {
|
||||||
|
throw new Error(authErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authType = isConnect ? "Connect" : "Service account";
|
||||||
|
|
||||||
|
core.info(`Authenticated with ${authType}.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractSecret = (
|
||||||
|
envName: string,
|
||||||
|
shouldExportEnv: boolean,
|
||||||
|
): void => {
|
||||||
|
core.info(`Populating variable: ${envName}`);
|
||||||
|
|
||||||
|
const ref = process.env[envName];
|
||||||
|
if (!ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretValue = read.parse(ref);
|
||||||
|
if (!secretValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldExportEnv) {
|
||||||
|
core.exportVariable(envName, secretValue);
|
||||||
|
} else {
|
||||||
|
core.setOutput(envName, secretValue);
|
||||||
|
}
|
||||||
|
core.setSecret(secretValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
||||||
|
// Pass User-Agent Information to the 1Password CLI
|
||||||
|
setClientInfo({
|
||||||
|
name: "1Password GitHub Action",
|
||||||
|
id: "GHA",
|
||||||
|
build: semverToInt(version),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load secrets from environment variables using 1Password CLI.
|
||||||
|
// Iterate over them to find 1Password references, extract the secret values,
|
||||||
|
// and make them available in the next steps either as step outputs or as environment variables.
|
||||||
|
const res = await exec.getExecOutput(`sh -c "op env ls"`);
|
||||||
|
|
||||||
|
if (res.stdout === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envs = res.stdout.replace(/\n+$/g, "").split(/\r?\n/);
|
||||||
|
for (const envName of envs) {
|
||||||
|
extractSecret(envName, shouldExportEnv);
|
||||||
|
}
|
||||||
|
if (shouldExportEnv) {
|
||||||
|
core.exportVariable(envManagedVariables, envs.join());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unsetPrevious = (): void => {
|
||||||
|
if (process.env[envManagedVariables]) {
|
||||||
|
core.info("Unsetting previous values ...");
|
||||||
|
const managedEnvs = process.env[envManagedVariables].split(",");
|
||||||
|
for (const envName of managedEnvs) {
|
||||||
|
core.info(`Unsetting ${envName}`);
|
||||||
|
core.exportVariable(envName, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
3
tests/.env.tpl
Normal file
3
tests/.env.tpl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FILE_SECRET=op://acceptance-tests/test-secret/password
|
||||||
|
FILE_SECRET_IN_SECTION=op://acceptance-tests/test-secret/test-section/password
|
||||||
|
FILE_MULTILINE_SECRET=op://acceptance-tests/multiline-secret/notesPlain
|
||||||
@@ -9,11 +9,8 @@ assert_env_equals() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_env_equals "SECRET" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
|
readonly SECRET="RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
|
||||||
|
MULTILINE_SECRET="$(cat << EOF
|
||||||
assert_env_equals "SECRET_IN_SECTION" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
|
|
||||||
|
|
||||||
assert_env_equals "MULTILINE_SECRET" "$(cat << EOF
|
|
||||||
-----BEGIN PRIVATE KEY-----
|
-----BEGIN PRIVATE KEY-----
|
||||||
RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLApXaGls
|
RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLApXaGls
|
||||||
ZSB3ZSBkZWVwbHkgYXBwcmVjaWF0ZSB5b3VyIHZp
|
ZSB3ZSBkZWVwbHkgYXBwcmVjaWF0ZSB5b3VyIHZp
|
||||||
@@ -28,3 +25,13 @@ IApTbyBwbGVhc2UgZG9uJ3QgcmVwb3J0IGl0IQo=
|
|||||||
-----END PRIVATE KEY-----
|
-----END PRIVATE KEY-----
|
||||||
EOF
|
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 "SECRET"
|
||||||
|
assert_env_unset "FILE_SECRET"
|
||||||
|
|
||||||
assert_env_unset "SECRET_IN_SECTION"
|
assert_env_unset "SECRET_IN_SECTION"
|
||||||
|
assert_env_unset "FILE_SECRET_IN_SECTION"
|
||||||
|
|
||||||
assert_env_unset "MULTILINE_SECRET"
|
assert_env_unset "MULTILINE_SECRET"
|
||||||
|
assert_env_unset "FILE_MULTILINE_SECRET"
|
||||||
|
|||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": false,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noEmit": true,
|
||||||
|
"noEmitOnError": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user