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