Compare commits

..

9 Commits

Author SHA1 Message Date
Dustin Ruetz
2722cfb5c8 feat(release.config.cjs): specify exported semantic-release object as GlobalConfig type
Some checks failed
Release / Release (push) Has been cancelled
Run acceptance tests / unit-tests (push) Has been cancelled
Run acceptance tests / test-with-output-secrets (connect, ubuntu-latest) (push) Has been cancelled
Run acceptance tests / test-with-output-secrets (service-account, macos-latest) (push) Has been cancelled
Run acceptance tests / test-with-output-secrets (service-account, ubuntu-latest) (push) Has been cancelled
Run acceptance tests / test-with-export-env (connect, ubuntu-latest) (push) Has been cancelled
Run acceptance tests / test-with-export-env (service-account, macos-latest) (push) Has been cancelled
Run acceptance tests / test-with-export-env (service-account, ubuntu-latest) (push) Has been cancelled
Run acceptance tests / test-references-with-ids (connect, ubuntu-latest) (push) Has been cancelled
Run acceptance tests / test-references-with-ids (service-account, macos-latest) (push) Has been cancelled
Run acceptance tests / test-references-with-ids (service-account, ubuntu-latest) (push) Has been cancelled
2024-04-04 21:17:49 -04:00
Dustin Ruetz
0b5bf555f1 fix: Git repo URL protocols (use SSH in package.json and HTTPS in release.config.cjs) 2024-04-04 21:16:10 -04:00
Dustin Ruetz
cca0f55ece docs: clarify code comments 2024-04-04 21:14:15 -04:00
Dustin Ruetz
baa29d5bd0 feat(release.config.cjs): initial configuration file for semantic-release 2024-04-04 16:34:09 -04:00
Dustin Ruetz
7ffac7f68a fix(package.json): switch 'repository.url' to HTTPS syntax 2024-04-04 16:28:41 -04:00
Dustin Ruetz
1b0b59a7a7 test: run release.yml workflow on push to this working branch 2024-04-03 18:36:21 -04:00
Dustin Ruetz
da98918135 test: run release.yml workflow on pull_request 2024-04-03 17:28:11 -04:00
Dustin Ruetz
e99f0139fe feat(.github/workflows): testing initial Release workflow file 2024-04-03 17:23:56 -04:00
Dustin Ruetz
82b420f3b7 fix(package.json): switch 'repository.url' to SSH syntax 2024-04-03 17:22:55 -04:00
53 changed files with 7202 additions and 40349 deletions

View File

@@ -1,322 +0,0 @@
name: E2E Tests
on:
# For local testing with: act push -W .github/workflows/e2e-tests.yml
push:
branches-ignore:
- "**" # Never runs on GitHub, only locally with act
# For test.yml to call this workflow
workflow_call:
inputs:
ref:
description: "Git ref to checkout"
required: true
type: string
secrets:
OP_CONNECT_CREDENTIALS:
required: true
OP_CONNECT_TOKEN:
required: true
OP_SERVICE_ACCOUNT_TOKEN:
required: true
VAULT:
description: "1Password vault name"
required: true
VAULT_ID:
description: "1Password vault 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
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build actions
run: npm run build:all
- name: Generate .env.tpl
shell: bash
run: |
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
echo "SECRET_WITH_FILE=op://${{ secrets.VAULT }}/file-secret/test.txt" >> tests/.env.tpl
echo "SECRET_WITH_FILE_IN_SECTION=op://${{ secrets.VAULT }}/file-secret/file section/test.txt" >> tests/.env.tpl
echo "DOUBLE_SECTION_SECRET=op://${{ secrets.VAULT }}/double-section-secret/test-section/password" >> tests/.env.tpl
- name: Generate .vaultId_env.tpl
shell: bash
run: |
echo "FILE_SECRET=op://${{ secrets.VAULT_ID }}/test-secret/password" > tests/.vaultId_env.tpl
echo "FILE_SECRET_IN_SECTION=op://${{ secrets.VAULT_ID }}/test-secret/test-section/password" >> tests/.vaultId_env.tpl
echo "FILE_MULTILINE_SECRET=op://${{ secrets.VAULT_ID }}/multiline-secret/notesPlain" >> tests/.vaultId_env.tpl
echo "SECRET_WITH_FILE=op://${{ secrets.VAULT_ID }}/file-secret/test.txt" >> tests/.vaultId_env.tpl
echo "SECRET_WITH_FILE_IN_SECTION=op://${{ secrets.VAULT_ID }}/file-secret/file section/test.txt" >> tests/.vaultId_env.tpl
echo "DOUBLE_SECTION_SECRET=op://${{ secrets.VAULT_ID }}/double-section-secret/test-section/password" >> tests/.vaultId_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
SECRET_WITH_FILE: op://${{ secrets.VAULT }}/file-secret/test.txt
SECRET_WITH_FILE_IN_SECTION: op://${{ secrets.VAULT }}/file-secret/file section/test.txt
DOUBLE_SECTION_SECRET: op://${{ secrets.VAULT }}/double-section-secret/test-section/password
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 }}
SECRET_WITH_FILE: ${{ steps.load_secrets.outputs.SECRET_WITH_FILE }}
SECRET_WITH_FILE_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_WITH_FILE_IN_SECTION }}
DOUBLE_SECTION_SECRET: ${{ steps.load_secrets.outputs.DOUBLE_SECTION_SECRET }}
run: ./tests/assert-env-set.sh
- name: Assert test secret values [exported env]
if: ${{ matrix.export-env }}
shell: bash
run: ./tests/assert-env-set.sh
- name: Remove secrets [exported env]
if: ${{ matrix.export-env }}
uses: ./
with:
unset-previous: true
- name: Assert removed secrets [exported env]
if: ${{ matrix.export-env }}
shell: bash
run: ./tests/assert-env-unset.sh
- name: Load secrets (invalid ref - expect failure)
id: load_invalid
continue-on-error: true
uses: ./
env:
BAD_REF: "op://x"
with:
export-env: true
- name: Assert invalid ref failed
shell: bash
run: ./tests/assert-invalid-ref-failed.sh
env:
STEP_OUTCOME: ${{ steps.load_invalid.outcome }}
- name: Load secrets by vault ID
id: load_secrets_by_vault_id
uses: ./
with:
version: ${{ matrix.version }}
export-env: ${{ matrix.export-env }}
env:
SECRET: op://${{ secrets.VAULT_ID }}/test-secret/password
SECRET_IN_SECTION: op://${{ secrets.VAULT_ID }}/test-secret/test-section/password
MULTILINE_SECRET: op://${{ secrets.VAULT_ID }}/multiline-secret/notesPlain
SECRET_WITH_FILE: op://${{ secrets.VAULT_ID }}/file-secret/test.txt
SECRET_WITH_FILE_IN_SECTION: op://${{ secrets.VAULT_ID }}/file-secret/file section/test.txt
DOUBLE_SECTION_SECRET: op://${{ secrets.VAULT_ID }}/double-section-secret/test-section/password
OP_ENV_FILE: ./tests/.vaultId_env.tpl
- name: Assert test secret values [vault by ID]
if: ${{ !matrix.export-env }}
shell: bash
env:
SECRET: ${{ steps.load_secrets_by_vault_id.outputs.SECRET }}
SECRET_IN_SECTION: ${{ steps.load_secrets_by_vault_id.outputs.SECRET_IN_SECTION }}
MULTILINE_SECRET: ${{ steps.load_secrets_by_vault_id.outputs.MULTILINE_SECRET }}
FILE_SECRET: ${{ steps.load_secrets_by_vault_id.outputs.FILE_SECRET }}
FILE_SECRET_IN_SECTION: ${{ steps.load_secrets_by_vault_id.outputs.FILE_SECRET_IN_SECTION }}
FILE_MULTILINE_SECRET: ${{ steps.load_secrets_by_vault_id.outputs.FILE_MULTILINE_SECRET }}
SECRET_WITH_FILE: ${{ steps.load_secrets_by_vault_id.outputs.SECRET_WITH_FILE }}
SECRET_WITH_FILE_IN_SECTION: ${{ steps.load_secrets_by_vault_id.outputs.SECRET_WITH_FILE_IN_SECTION }}
DOUBLE_SECTION_SECRET: ${{ steps.load_secrets_by_vault_id.outputs.DOUBLE_SECTION_SECRET }}
run: ./tests/assert-env-set.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
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build actions
run: npm run build:all
- name: Generate .env.tpl
run: |
mkdir -p tests
echo "FILE_SECRET=op://${{ secrets.VAULT }}/test-secret/password" > tests/.env.tpl
echo "FILE_SECRET_IN_SECTION=op://${{ secrets.VAULT }}/test-secret/test-section/password" >> tests/.env.tpl
echo "FILE_MULTILINE_SECRET=op://${{ secrets.VAULT }}/multiline-secret/notesPlain" >> tests/.env.tpl
echo "SECRET_WITH_FILE=op://${{ secrets.VAULT }}/file-secret/test.txt" >> tests/.env.tpl
echo "SECRET_WITH_FILE_IN_SECTION=op://${{ secrets.VAULT }}/file-secret/file section/test.txt" >> tests/.env.tpl
echo "DOUBLE_SECTION_SECRET=op://${{ secrets.VAULT }}/double-section-secret/test-section/password" >> tests/.env.tpl
- name: Generate .vaultId_env.tpl
run: |
echo "FILE_SECRET=op://${{ secrets.VAULT_ID }}/test-secret/password" > tests/.vaultId_env.tpl
echo "FILE_SECRET_IN_SECTION=op://${{ secrets.VAULT_ID }}/test-secret/test-section/password" >> tests/.vaultId_env.tpl
echo "FILE_MULTILINE_SECRET=op://${{ secrets.VAULT_ID }}/multiline-secret/notesPlain" >> tests/.vaultId_env.tpl
echo "SECRET_WITH_FILE=op://${{ secrets.VAULT_ID }}/file-secret/test.txt" >> tests/.vaultId_env.tpl
echo "SECRET_WITH_FILE_IN_SECTION=op://${{ secrets.VAULT_ID }}/file-secret/file section/test.txt" >> tests/.vaultId_env.tpl
echo "DOUBLE_SECTION_SECRET=op://${{ secrets.VAULT_ID }}/double-section-secret/test-section/password" >> tests/.vaultId_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 30
- 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
SECRET_WITH_FILE: op://${{ secrets.VAULT }}/file-secret/test.txt
SECRET_WITH_FILE_IN_SECTION: op://${{ secrets.VAULT }}/file-secret/file section/test.txt
DOUBLE_SECTION_SECRET: op://${{ secrets.VAULT }}/double-section-secret/test-section/password
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 }}
SECRET_WITH_FILE: ${{ steps.load_secrets.outputs.SECRET_WITH_FILE }}
SECRET_WITH_FILE_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_WITH_FILE_IN_SECTION }}
DOUBLE_SECTION_SECRET: ${{ steps.load_secrets.outputs.DOUBLE_SECTION_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
- name: Load secrets (invalid ref - expect failure)
id: load_invalid
continue-on-error: true
uses: ./
env:
BAD_REF: "op://x"
with:
export-env: true
- name: Assert invalid ref failed
shell: bash
run: ./tests/assert-invalid-ref-failed.sh
env:
STEP_OUTCOME: ${{ steps.load_invalid.outcome }}
- name: Load secrets by vault ID
id: load_secrets_by_vault_id
uses: ./
with:
version: ${{ matrix.version }}
export-env: ${{ matrix.export-env }}
env:
SECRET: op://${{ secrets.VAULT_ID }}/test-secret/password
SECRET_IN_SECTION: op://${{ secrets.VAULT_ID }}/test-secret/test-section/password
MULTILINE_SECRET: op://${{ secrets.VAULT_ID }}/multiline-secret/notesPlain
SECRET_WITH_FILE: op://${{ secrets.VAULT_ID }}/file-secret/test.txt
SECRET_WITH_FILE_IN_SECTION: op://${{ secrets.VAULT_ID }}/file-secret/file section/test.txt
DOUBLE_SECTION_SECRET: op://${{ secrets.VAULT_ID }}/double-section-secret/test-section/password
OP_ENV_FILE: ./tests/.vaultId_env.tpl
- name: Assert test secret values [vault by ID]
if: ${{ !matrix.export-env }}
shell: bash
env:
SECRET: ${{ steps.load_secrets_by_vault_id.outputs.SECRET }}
SECRET_IN_SECTION: ${{ steps.load_secrets_by_vault_id.outputs.SECRET_IN_SECTION }}
MULTILINE_SECRET: ${{ steps.load_secrets_by_vault_id.outputs.MULTILINE_SECRET }}
FILE_SECRET: ${{ steps.load_secrets_by_vault_id.outputs.FILE_SECRET }}
FILE_SECRET_IN_SECTION: ${{ steps.load_secrets_by_vault_id.outputs.FILE_SECRET_IN_SECTION }}
FILE_MULTILINE_SECRET: ${{ steps.load_secrets_by_vault_id.outputs.FILE_MULTILINE_SECRET }}
SECRET_WITH_FILE: ${{ steps.load_secrets_by_vault_id.outputs.SECRET_WITH_FILE }}
SECRET_WITH_FILE_IN_SECTION: ${{ steps.load_secrets_by_vault_id.outputs.SECRET_WITH_FILE_IN_SECTION }}
DOUBLE_SECTION_SECRET: ${{ steps.load_secrets_by_vault_id.outputs.DOUBLE_SECTION_SECRET }}
run: ./tests/assert-env-set.sh

View File

@@ -1,36 +0,0 @@
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 Normal file
View File

@@ -0,0 +1,13 @@
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

View File

@@ -1,25 +0,0 @@
# 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

View File

@@ -1,13 +0,0 @@
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

44
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Release
on:
push:
branches:
# TODO: This branch is for PR testing purposes; update branch to "main" if we proceed with this PR.
- ruetz-automate-releases
# Specify that this "Release" workflow depends on the other workflows below completing successfully.
workflow_run:
workflows: ["Lint", "Run acceptance tests"]
types:
- completed
permissions:
contents: read # for checkout
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
id-token: write # to enable use of OIDC for npm provenance
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install dependencies
run: npm clean-install
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
run: npm audit signatures
- name: Build codebase
run: npm run build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release

View File

@@ -1,121 +0,0 @@
name: E2E Tests
on:
push:
branches: [main]
paths-ignore: &ignore_paths
- "docs/**"
- "config/**"
- "*.md"
- ".gitignore"
- "LICENSE"
pull_request:
paths-ignore: *ignore_paths
repository_dispatch:
types: [ok-to-test-command]
concurrency:
group: >-
${{ github.event_name == 'pull_request' &&
format('e2e-{0}', github.event.pull_request.head.ref) ||
format('e2e-{0}', github.ref) }}
cancel-in-progress: true
jobs:
check-external-pr:
runs-on: ubuntu-latest
outputs:
condition: ${{ steps.check.outputs.condition }}
ref: ${{ steps.check.outputs.ref }}
steps:
- name: Check if PR is from external contributor
id: check
run: |
echo "Event name: ${{ github.event_name }}"
echo "Repository: ${{ github.repository }}"
if [ "${{ github.event_name }}" == "pull_request" ]; then
# For pull_request events, check if PR is from external fork
echo "PR head repo: ${{ github.event.pull_request.head.repo.full_name }}"
if [ "${{ github.actor }}" == "dependabot[bot]" ]; then
echo "condition=skip" >> $GITHUB_OUTPUT
echo "Setting condition=skip (Dependabot PR)"
elif [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
echo "condition=skip" >> $GITHUB_OUTPUT
echo "Setting condition=skip (external fork PR creation)"
else
echo "condition=pr-creation-maintainer" >> $GITHUB_OUTPUT
echo "Setting condition=pr-creation-maintainer (internal PR creation)"
echo "ref=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
fi
elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then
# For repository_dispatch events (ok-to-test), check if sha matches
SHA_PARAM="${{ github.event.client_payload.slash_command.args.named.sha }}"
PR_HEAD_SHA="${{ github.event.client_payload.pull_request.head.sha }}"
echo "Checking dispatch event conditions..."
echo "SHA from command: $SHA_PARAM"
echo "PR head SHA: $PR_HEAD_SHA"
if [ -n "$SHA_PARAM" ] && [[ "$PR_HEAD_SHA" == *"$SHA_PARAM"* ]]; then
echo "condition=dispatch-event" >> $GITHUB_OUTPUT
echo "Setting condition=dispatch-event (sha matches)"
echo "ref=$PR_HEAD_SHA" >> $GITHUB_OUTPUT
else
echo "condition=skip" >> $GITHUB_OUTPUT
echo "Setting condition=skip (sha does not match or empty)"
fi
elif [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref_name }}" == "main" ]; then
echo "condition=push-to-main" >> $GITHUB_OUTPUT
echo "Setting condition=push-to-main (push to main)"
echo "ref=${{ github.sha }}" >> $GITHUB_OUTPUT
else
# Unknown event type
echo "condition=skip" >> $GITHUB_OUTPUT
echo "Setting condition=skip (unknown event type: ${{ github.event_name }})"
fi
e2e:
needs: check-external-pr
if: |
(needs.check-external-pr.outputs.condition == 'pr-creation-maintainer')
||
(needs.check-external-pr.outputs.condition == 'dispatch-event')
||
needs.check-external-pr.outputs.condition == 'push-to-main'
uses: ./.github/workflows/e2e-tests.yml
with:
ref: ${{ needs.check-external-pr.outputs.ref }}
secrets:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
VAULT: ${{ secrets.VAULT }}
VAULT_ID: ${{ secrets.VAULT_ID }}
# 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 }}

146
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,146 @@
on: push
name: Run acceptance tests
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
- run: npm ci
- run: npm test
test-with-output-secrets:
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://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
View File

@@ -1,4 +1,2 @@
coverage/
node_modules/
.idea/
1password-credentials.json

View File

@@ -3,7 +3,6 @@
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:
@@ -11,15 +10,13 @@ After following the steps below for signing commits, you can test against your P
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>
```
```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.

View File

@@ -23,77 +23,38 @@ Read more on the [1Password Developer Portal](https://developer.1password.com/do
## ✨ Quickstart
### Export secrets as a step's output (recommended)
```yml
on: push
jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Load secret
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
uses: 1password/load-secrets-action@v2
with:
# Export loaded secrets as environment variables
export-env: true
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
SECRET: op://app-cicd/hello-world/secret
OP_ENV_FILE: "./path/to/.env.tpl" # see tests/.env.tpl for example
- name: Print masked secret
run: 'echo "Secret: $SECRET"'
# Prints: Secret: ***
```
### 🔑 SSH Key Format
When loading SSH keys, you can specify the format using the `ssh-format` query parameter. This is useful when you need the private key in a specific format like OpenSSH.
```yml
- name: Load SSH key
uses: 1password/load-secrets-action@v3
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
# Load SSH private key in OpenSSH format
SSH_PRIVATE_KEY: op://vault/item/private key?ssh-format=openssh
```
For more details on secret reference syntax, see the [1Password CLI documentation](https://developer.1password.com/docs/cli/secret-reference-syntax/#ssh-format-parameter).
## 💙 Community & Support
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
- Join the [Developer Slack workspace](https://developer.1password.com/joinslack).
- Join the [Developer Slack workspace](https://join.slack.com/t/1password-devs/shared_invite/zt-1halo11ps-6o9pEv96xZ3LtX_VE0fJQA).
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
## 🔐 Security
1Password requests you practice responsible disclosure if you discover a vulnerability.
Please file requests by sending an email to bugbounty@agilebits.com.
Please file requests via [**BugCrowd**](https://bugcrowd.com/agilebits).
For information about security practices, please visit the [1Password Bug Bounty Program](https://bugcrowd.com/agilebits).

View File

@@ -10,10 +10,7 @@ inputs:
default: "false"
export-env:
description: Export the secrets as environment variables
default: "false"
version:
description: Specify which 1Password CLI version to install. Defaults to "latest".
default: "latest"
default: "true"
runs:
using: "node20"
main: "dist/index.js"

View File

@@ -9,5 +9,12 @@ inputs:
service-account-token:
description: Your 1Password service account token
runs:
using: "node20"
main: "dist/index.js"
using: composite
steps:
- shell: bash
env:
INPUT_CONNECT_HOST: ${{ inputs.connect-host }}
INPUT_CONNECT_TOKEN: ${{ inputs.connect-token }}
INPUT_SERVICE_ACCOUNT_TOKEN: ${{ inputs.service-account-token }}
run: |
${{ github.action_path }}/entrypoint.sh

27588
configure/dist/index.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{
"type": "commonjs"
}

21
configure/entrypoint.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/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

View File

@@ -1,27 +0,0 @@
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();

10764
dist/index.js vendored

File diff suppressed because one or more lines are too long

2
dist/package.json vendored
View File

@@ -1,3 +1,3 @@
{
"type": "commonjs"
"type": "module"
}

View File

@@ -1,32 +0,0 @@
# 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.

View File

@@ -1,47 +0,0 @@
# 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 |
| `VAULT_ID` | Vault 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
```

46
install_cli.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
set -e
# 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
echo "::debug::OP_INSTALL_DIR: ${OP_INSTALL_DIR}"
# Get the latest stable version of the CLI
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/${CLI_VERSION}/op_linux_${ARCH}_${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/${CLI_VERSION}/op_apple_universal_${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
}
install_op_cli

6036
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,14 @@
{
"name": "load-secrets-action",
"version": "3.1.0",
"version": "2.0.0",
"description": "Load Secrets from 1Password",
"type": "module",
"main": "dist/index.js",
"directories": {
"test": "tests"
},
"scripts": {
"build": "ncc build ./src/index.ts",
"build:configure": "ncc build ./configure/index.js -o ./configure/dist",
"build:all": "npm run build && npm run build:configure",
"format": "prettier --ignore-path ./config/.prettierignore",
"format:check": "npm run format -- --check ./",
"format:write": "npm run format -- --write ./",
@@ -25,7 +24,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/1Password/load-secrets-action.git"
"url": "git@github.com:1Password/load-secrets-action.git"
},
"keywords": [
"actions",
@@ -41,16 +40,11 @@
"homepage": "https://github.com/1Password/load-secrets-action#readme",
"dependencies": {
"@1password/op-js": "^0.1.11",
"@1password/sdk": "^0.4.0",
"@1password/connect": "^1.4.2",
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/tool-cache": "^2.0.2",
"dotenv": "^17.2.2"
"@actions/exec": "^1.1.1"
},
"devDependencies": {
"@1password/eslint-config": "^4.3.1",
"@1password/prettier-config": "^1.2.0",
"@1password/front-end-style": "^6.0.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.30",
"@vercel/ncc": "^0.38.1",
@@ -61,7 +55,7 @@
"typescript": "^5.4.2"
},
"eslintConfig": {
"extends": "@1password/eslint-config",
"extends": "./node_modules/@1password/front-end-style/eslintrc.yml",
"ignorePatterns": [
"coverage/"
],
@@ -69,5 +63,5 @@
"project": "./tsconfig.json"
}
},
"prettier": "@1password/prettier-config"
"prettier": "./node_modules/@1password/front-end-style/prettierrc.json"
}

15
release.config.cjs Normal file
View File

@@ -0,0 +1,15 @@
/** @type {import('semantic-release').GlobalConfig} */
module.exports = {
// TODO: This branch is for PR testing purposes; update branch to "main" if we proceed with this PR.
branches: ["ruetz-automate-releases"],
// TODO: Remove `dryRun` configuration if we proceed with this PR.
dryRun: true,
plugins: [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/github",
],
// Use the `https` Git protocol here to prevent semantic-release from erroring
// on the SSH protocol used in `repository.url` in the package.json file.
repositoryUrl: "https://github.com/1Password/load-secrets-action.git",
};

View File

@@ -2,6 +2,5 @@ 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}.`;

View File

@@ -1,7 +1,9 @@
import dotenv from "dotenv";
import path from "path";
import url from "url";
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { validateCli } from "@1password/op-js";
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
import { envFilePath } from "./constants";
const loadSecretsAction = async () => {
try {
@@ -17,12 +19,8 @@ const loadSecretsAction = async () => {
// 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);
@@ -34,10 +32,36 @@ const loadSecretsAction = async () => {
if (error instanceof Error) {
message = error.message;
} else {
message = String(error);
String(error);
}
core.setFailed(message);
}
};
// This function's name is an exception from the naming convention
// since we refer to the 1Password CLI here.
// eslint-disable-next-line @typescript-eslint/naming-convention
const installCLI = async (): Promise<void> => {
// validateCli checks if there's an existing 1Password CLI installed on the runner.
// If there's no CLI installed, then validateCli will throw an error, which we will use
// as an indicator that we need to execute the installation script.
await validateCli().catch(async () => {
const currentFile = url.fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFile);
const parentDir = path.resolve(currentDir, "..");
// Execute bash script
const cmdOut = await exec.getExecOutput(
`sh -c "` + parentDir + `/install_cli.sh"`,
);
// Add path to 1Password CLI to $PATH
const outArr = cmdOut.stdout.split("\n");
if (outArr[0] && process.env.PATH) {
const cliPath = outArr[0]?.replace(/^(::debug::OP_INSTALL_DIR: )/, "");
core.addPath(cliPath);
}
});
};
void loadSecretsAction();

View File

@@ -1,58 +0,0 @@
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;
}
}

View File

@@ -1 +0,0 @@
export { type Installer, newCliInstaller } from "./installer";

View File

@@ -1,43 +0,0 @@
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",
);
});
});

View File

@@ -1,23 +0,0 @@
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}`);
}
};

View File

@@ -1,38 +0,0 @@
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);
});
});

View File

@@ -1,19 +0,0 @@
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));
}
}

View File

@@ -1,35 +0,0 @@
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);
});
});

View File

@@ -1,49 +0,0 @@
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");
}
}

View File

@@ -1,38 +0,0 @@
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);
});
});

View File

@@ -1,19 +0,0 @@
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));
}
}

View File

@@ -1,18 +0,0 @@
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();
};

View File

@@ -1,81 +0,0 @@
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();
});
});

View File

@@ -1,2 +0,0 @@
export { installCliOnGithubActionRunner } from "./github-action";
export { ReleaseChannel, VersionResolver } from "./version";

View File

@@ -1,13 +0,0 @@
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 };
};
}

View File

@@ -1,91 +0,0 @@
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`,
);
});
});

View File

@@ -1,23 +0,0 @@
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;
};

View File

@@ -1,2 +0,0 @@
export { VersionResolver } from "./version-resolver";
export { ReleaseChannel } from "./constants";

View File

@@ -1,45 +0,0 @@
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();
});
});

View File

@@ -1,23 +0,0 @@
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}`);
};

View File

@@ -1,58 +0,0 @@
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");
});
});

View File

@@ -1,45 +0,0 @@
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);
}
}

View File

@@ -1,17 +1,11 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { read } from "@1password/op-js";
import { createClient, Secrets } from "@1password/sdk";
import { OnePasswordConnect, FullItem } from "@1password/connect";
import { read, setClientInfo } from "@1password/op-js";
import {
extractSecret,
loadSecrets,
unsetPrevious,
validateAuth,
findMatchingFieldAndFile,
findSectionIdsByQuery,
parseOpRef,
getEnvVarNamesWithSecretRefs,
} from "./utils";
import {
authErr,
@@ -28,14 +22,6 @@ jest.mock("@actions/exec", () => ({
})),
}));
jest.mock("@1password/op-js");
jest.mock("@1password/sdk", () => ({
createClient: jest.fn(),
// eslint-disable-next-line @typescript-eslint/naming-convention
Secrets: {
validateSecretReference: jest.fn(),
},
}));
jest.mock("@1password/connect");
beforeEach(() => {
jest.clearAllMocks();
@@ -53,24 +39,24 @@ describe("validateAuth", () => {
});
it("should throw an error when no config is provided", () => {
expect(validateAuth).toThrow(authErr);
expect(validateAuth).toThrowError(authErr);
});
it("should throw an error when partial Connect config is provided", () => {
process.env[envConnectHost] = testConnectHost;
expect(validateAuth).toThrow(authErr);
expect(validateAuth).toThrowError(authErr);
});
it("should be authenticated as a Connect client", () => {
process.env[envConnectHost] = testConnectHost;
process.env[envConnectToken] = testConnectToken;
expect(validateAuth).not.toThrow(authErr);
expect(validateAuth).not.toThrowError(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(validateAuth).not.toThrowError(authErr);
expect(core.info).toHaveBeenCalledWith(
"Authenticated with Service account.",
);
@@ -80,7 +66,7 @@ describe("validateAuth", () => {
process.env[envServiceAccountToken] = testServiceAccountToken;
process.env[envConnectHost] = testConnectHost;
process.env[envConnectToken] = testConnectToken;
expect(validateAuth).not.toThrow(authErr);
expect(validateAuth).not.toThrowError(authErr);
expect(core.warning).toHaveBeenCalled();
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
});
@@ -120,264 +106,36 @@ describe("extractSecret", () => {
);
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
});
describe("when secret value is empty string", () => {
const emptySecretValue = "";
beforeEach(() => {
(read.parse as jest.Mock).mockReturnValue(emptySecretValue);
});
afterEach(() => {
(read.parse as jest.Mock).mockReturnValue(testSecretValue);
});
it("should set empty string as step output", () => {
extractSecret(envTestSecretEnv, false);
expect(core.setOutput).toHaveBeenCalledWith(
envTestSecretEnv,
emptySecretValue,
);
expect(core.exportVariable).not.toHaveBeenCalled();
});
it("should set empty string as environment variable", () => {
extractSecret(envTestSecretEnv, true);
expect(core.exportVariable).toHaveBeenCalledWith(
envTestSecretEnv,
emptySecretValue,
);
expect(core.setOutput).not.toHaveBeenCalled();
});
it("should not call setSecret for empty string", () => {
extractSecret(envTestSecretEnv, false);
expect(core.setSecret).not.toHaveBeenCalled();
});
});
});
describe("loadSecrets when using Connect", () => {
beforeEach(() => {
process.env[envConnectHost] = "https://connect.example";
process.env[envConnectToken] = "test-token";
process.env[envServiceAccountToken] = "";
Object.keys(process.env).forEach((key) => {
if (
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://")
) {
delete process.env[key];
}
});
process.env.MY_SECRET = "op://vault/item/field";
(OnePasswordConnect as jest.Mock).mockReturnValue({
getVault: jest.fn().mockResolvedValue({ id: "vault-id-123" }),
getItem: jest.fn().mockResolvedValue({
fields: [
{ label: "field", value: "resolved-via-connect", section: undefined },
],
sections: [],
}),
});
});
it("resolves ref via Connect SDK and exports secret", async () => {
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(
"MY_SECRET",
"resolved-via-connect",
);
expect(core.exportVariable).toHaveBeenCalledWith(
envManagedVariables,
"MY_SECRET",
"OP_MANAGED_VARIABLES",
"MOCK_SECRET",
);
});
it("return early if no env vars with secrets found", async () => {
delete process.env.MY_SECRET;
(exec.getExecOutput as jest.Mock).mockReturnValueOnce({ stdout: "" });
await loadSecrets(true);
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
expect(core.exportVariable).not.toHaveBeenCalled();
});
it("sets step output when shouldExportEnv is false", async () => {
await loadSecrets(false);
expect(core.setOutput).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-via-connect",
);
expect(core.exportVariable).not.toHaveBeenCalled();
});
it("masks resolved secret with setSecret", async () => {
await loadSecrets(true);
expect(core.setSecret).toHaveBeenCalledWith("resolved-via-connect");
});
it("calls getVault with vault segment from ref", async () => {
process.env.MY_SECRET = "op://my-vault-name/my-item/field";
const mockGetVault = jest.fn().mockResolvedValue({ id: "vault-uuid" });
const mockGetItem = jest.fn().mockResolvedValue({
fields: [{ label: "field", value: "secret-value", section: undefined }],
sections: [],
});
(OnePasswordConnect as jest.Mock).mockReturnValue({
getVault: mockGetVault,
getItem: mockGetItem,
});
await loadSecrets(false);
expect(mockGetVault).toHaveBeenCalledWith("my-vault-name");
});
it("throws when getVault returns vault without id", async () => {
const mockGetVault = jest.fn().mockResolvedValue({});
(OnePasswordConnect as jest.Mock).mockReturnValue({
getVault: mockGetVault,
getItem: jest.fn(),
});
await expect(loadSecrets(true)).rejects.toThrow(
/Could not find valid vault "vault" for ref "op:\/\/vault\/item\/field"/,
);
expect(mockGetVault).toHaveBeenCalledWith("vault");
});
it("resolves vault by name and uses returned id for getItem", async () => {
process.env.MY_SECRET = "op://My Vault/My Item/field";
const mockGetVault = jest
.fn()
.mockResolvedValue({ id: "uuid-for-my-vault" });
const mockGetItem = jest.fn().mockResolvedValue({
fields: [
{
label: "field",
value: "secret-from-named-vault",
section: undefined,
},
],
sections: [],
});
(OnePasswordConnect as jest.Mock).mockReturnValue({
getVault: mockGetVault,
getItem: mockGetItem,
});
await loadSecrets(true);
expect(mockGetVault).toHaveBeenCalledWith("My Vault");
expect(mockGetItem).toHaveBeenCalledWith("uuid-for-my-vault", "My Item");
expect(core.exportVariable).toHaveBeenCalledWith(
"MY_SECRET",
"secret-from-named-vault",
);
});
it("calls getItem with vault id from getVault, not ref vault segment", async () => {
const mockGetVault = jest
.fn()
.mockResolvedValue({ id: "resolved-vault-id" });
const mockGetItem = jest.fn().mockResolvedValue({
fields: [
{ label: "field", value: "resolved-via-connect", section: undefined },
],
sections: [],
});
(OnePasswordConnect as jest.Mock).mockReturnValue({
getVault: mockGetVault,
getItem: mockGetItem,
});
await loadSecrets(true);
expect(mockGetVault).toHaveBeenCalledWith("vault");
expect(mockGetItem).toHaveBeenCalledWith("resolved-vault-id", "item");
});
it("rejects when getItem fails", async () => {
const mockGetVault = jest.fn().mockResolvedValue({ id: "vault-id-123" });
const mockGetItem = jest
.fn()
.mockRejectedValue(new Error("Item not found"));
(OnePasswordConnect as jest.Mock).mockReturnValue({
getVault: mockGetVault,
getItem: mockGetItem,
});
await expect(loadSecrets(true)).rejects.toThrow("Item not found");
});
it("resolves refs in different vaults using each vault id", async () => {
delete process.env.MY_SECRET;
process.env.SECRET_A = "op://vault-a/item1/field1";
process.env.SECRET_B = "op://vault-b/item2/field2";
const mockGetVault = jest
.fn()
.mockImplementation(async (vaultName: string) =>
Promise.resolve({
id: vaultName === "vault-a" ? "id-a" : "id-b",
}),
);
const mockGetItem = jest
.fn()
.mockResolvedValueOnce({
fields: [{ label: "field1", value: "value-a", section: undefined }],
sections: [],
})
.mockResolvedValueOnce({
fields: [{ label: "field2", value: "value-b", section: undefined }],
sections: [],
});
(OnePasswordConnect as jest.Mock).mockReturnValue({
getVault: mockGetVault,
getItem: mockGetItem,
});
await loadSecrets(true);
expect(mockGetVault).toHaveBeenCalledWith("vault-a");
expect(mockGetVault).toHaveBeenCalledWith("vault-b");
expect(mockGetItem).toHaveBeenNthCalledWith(1, "id-a", "item1");
expect(mockGetItem).toHaveBeenNthCalledWith(2, "id-b", "item2");
expect(core.exportVariable).toHaveBeenCalledWith("SECRET_A", "value-a");
expect(core.exportVariable).toHaveBeenCalledWith("SECRET_B", "value-b");
});
it("throws on invalid ref before calling Connect", async () => {
delete process.env.MY_SECRET;
process.env.BAD_REF = "op://x";
const mockGetVault = jest.fn();
const mockGetItem = jest.fn();
(OnePasswordConnect as jest.Mock).mockReturnValue({
getVault: mockGetVault,
getItem: mockGetItem,
});
await expect(loadSecrets(true)).rejects.toThrow(/invalid|reference/i);
expect(mockGetVault).not.toHaveBeenCalled();
expect(mockGetItem).not.toHaveBeenCalled();
});
describe("core.exportVariable", () => {
it("is called when shouldExportEnv is true", async () => {
await loadSecrets(true);
expect(core.exportVariable).toHaveBeenCalledTimes(2);
expect(core.exportVariable).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-via-connect",
);
expect(core.exportVariable).toHaveBeenCalledWith(
envManagedVariables,
"MY_SECRET",
);
expect(core.exportVariable).toHaveBeenCalledTimes(1);
});
it("is not called when shouldExportEnv is false", async () => {
@@ -388,199 +146,6 @@ describe("loadSecrets when using Connect", () => {
});
});
describe("loadSecrets when using Service Account", () => {
const mockResolve = jest.fn();
beforeEach(() => {
process.env[envConnectHost] = "";
process.env[envConnectToken] = "";
process.env[envServiceAccountToken] = "ops_token";
Object.keys(process.env).forEach((key) => {
if (
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://")
) {
delete process.env[key];
}
});
process.env.MY_SECRET = "op://vault/item/field";
(createClient as jest.Mock).mockResolvedValue({
secrets: { resolve: mockResolve },
});
mockResolve.mockResolvedValue("resolved-secret-value");
});
it("does not call op env ls when using Service Account", async () => {
await loadSecrets(false);
expect(exec.getExecOutput).not.toHaveBeenCalled();
});
it("sets step output with resolved value when export-env is false", async () => {
await loadSecrets(false);
expect(core.setOutput).toHaveBeenCalledTimes(1);
expect(core.setOutput).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-secret-value",
);
});
it("masks secret with setSecret when export-env is false", async () => {
await loadSecrets(false);
expect(core.setSecret).toHaveBeenCalledTimes(1);
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
});
it("does not call exportVariable when export-env is false", async () => {
await loadSecrets(false);
expect(core.exportVariable).not.toHaveBeenCalled();
});
it("exports env and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
await loadSecrets(true);
expect(core.exportVariable).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-secret-value",
);
expect(core.exportVariable).toHaveBeenCalledWith(
envManagedVariables,
"MY_SECRET",
);
});
it("does not set step output when export-env is true", async () => {
await loadSecrets(true);
expect(core.setOutput).not.toHaveBeenCalledWith(
"MY_SECRET",
expect.anything(),
);
});
it("masks secret with setSecret when export-env is true", async () => {
await loadSecrets(true);
expect(core.setSecret).toHaveBeenCalledTimes(1);
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
});
it("returns early when no env vars have op:// refs", async () => {
Object.keys(process.env).forEach((key) => {
if (
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://")
) {
delete process.env[key];
}
});
await loadSecrets(true);
expect(exec.getExecOutput).not.toHaveBeenCalled();
expect(core.exportVariable).not.toHaveBeenCalled();
});
it("wraps createClient errors with a descriptive message", async () => {
(createClient as jest.Mock).mockRejectedValue(
new Error("invalid token format"),
);
await expect(loadSecrets(false)).rejects.toThrow(
"Service account authentication failed: invalid token format",
);
});
describe("multiple refs", () => {
const ref1 = "op://vault/item/field";
const ref2 = "op://vault/other/item";
const ref3 = "op://vault/file/secret";
beforeEach(() => {
process.env.MY_SECRET = ref1;
process.env.ANOTHER_SECRET = ref2;
process.env.FILE_SECRET = ref3;
mockResolve
.mockResolvedValueOnce("value1")
.mockResolvedValueOnce("value2")
.mockResolvedValueOnce("value3");
});
it("resolves each ref and sets step output for each when export-env is false", async () => {
await loadSecrets(false);
expect(mockResolve).toHaveBeenCalledTimes(3);
expect(mockResolve).toHaveBeenCalledWith(ref1);
expect(mockResolve).toHaveBeenCalledWith(ref2);
expect(mockResolve).toHaveBeenCalledWith(ref3);
expect(core.setOutput).toHaveBeenCalledTimes(3);
expect(core.setOutput).toHaveBeenCalledWith("MY_SECRET", "value1");
expect(core.setOutput).toHaveBeenCalledWith("ANOTHER_SECRET", "value2");
expect(core.setOutput).toHaveBeenCalledWith("FILE_SECRET", "value3");
expect(core.setSecret).toHaveBeenCalledTimes(3);
});
it("resolves each ref and exports each and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
await loadSecrets(true);
expect(mockResolve).toHaveBeenCalledTimes(3);
expect(core.exportVariable).toHaveBeenCalledWith("MY_SECRET", "value1");
expect(core.exportVariable).toHaveBeenCalledWith(
"ANOTHER_SECRET",
"value2",
);
expect(core.exportVariable).toHaveBeenCalledWith("FILE_SECRET", "value3");
const exportVariableCalls = (core.exportVariable as jest.Mock).mock
.calls as [string, string][];
const managedVarsCall = exportVariableCalls.find(
([name]) => name === envManagedVariables,
);
expect(managedVarsCall).toBeDefined();
const managedList = (managedVarsCall as [string, string])[1].split(",");
expect(managedList).toContain("MY_SECRET");
expect(managedList).toContain("ANOTHER_SECRET");
expect(managedList).toContain("FILE_SECRET");
expect(managedList).toHaveLength(3);
expect(core.setSecret).toHaveBeenCalledTimes(3);
});
});
describe("secret reference validation", () => {
it("fails with clear message when a secret reference is invalid", async () => {
process.env.MY_SECRET = "op://x";
(Secrets.validateSecretReference as jest.Mock).mockImplementationOnce(
() => {
throw new Error("invalid reference format");
},
);
await expect(loadSecrets(true)).rejects.toThrow(
"Invalid secret reference(s): MY_SECRET",
);
expect(mockResolve).not.toHaveBeenCalled();
});
it("validates all refs before resolving any secrets", async () => {
process.env.MY_SECRET = "op://vault/item/field";
process.env.OTHER = "op://vault/other/item";
(Secrets.validateSecretReference as jest.Mock).mockImplementation(
(ref: string) => {
if (ref === "op://vault/other/item") {
throw new Error("invalid");
}
},
);
await expect(loadSecrets(false)).rejects.toThrow(
"Invalid secret reference(s): OTHER",
);
expect(mockResolve).not.toHaveBeenCalled();
});
});
});
describe("unsetPrevious", () => {
const testManagedEnv = "TEST_SECRET";
const testSecretValue = "MyS3cr#T";
@@ -597,360 +162,3 @@ describe("unsetPrevious", () => {
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
});
});
describe("findMatchingFieldAndFile", () => {
interface TestField {
id?: string;
label?: string;
value?: string | null;
section?: { id: string } | null | undefined;
}
interface TestFile {
id?: string;
name?: string;
section?: { id: string } | null | undefined;
}
const item = (opts: { fields?: TestField[]; files?: TestFile[] }): FullItem =>
({
fields: opts.fields ?? [],
files: opts.files ?? [],
sections: [],
}) as unknown as FullItem;
const find = (
opts: { fields?: TestField[]; files?: TestFile[] },
sectionIds: string[] = [],
) => findMatchingFieldAndFile(item(opts), "password", sectionIds);
describe("when section filter is used (sectionIds.length > 0)", () => {
it.each<{
name: string;
itemOpts: { fields?: TestField[]; files?: TestFile[] };
expected: { fieldValue?: string; fileId?: string };
}>([
{
name: "returns field value when one field matches query and is in ref sections",
itemOpts: {
fields: [
{
id: "f1",
label: "password",
value: "secret123",
section: { id: "section-1" },
},
],
},
expected: { fieldValue: "secret123" },
},
{
name: "returns file id when one file matches query and is in ref sections",
itemOpts: {
files: [
{
id: "file-uuid",
name: "password",
section: { id: "section-1" },
},
],
},
expected: { fileId: "file-uuid" },
},
{
name: "returns empty object when no field or file matches",
itemOpts: {
fields: [
{ label: "other", value: "x", section: { id: "section-1" } },
],
files: [],
},
expected: {},
},
{
name: "returns field value when field matches by id",
itemOpts: {
fields: [
{
id: "password",
label: "Password Label",
value: "secret-by-id",
section: { id: "section-1" },
},
],
},
expected: { fieldValue: "secret-by-id" },
},
])("$name", ({ itemOpts, expected }) => {
expect(find(itemOpts, ["section-1"])).toEqual(expected);
});
it.each<{
name: string;
itemOpts: { fields?: TestField[]; files?: TestFile[] };
error: RegExp;
}>([
{
name: "throws when multiple fields match",
itemOpts: {
fields: [
{ label: "password", value: "a", section: { id: "section-1" } },
{ label: "password", value: "b", section: { id: "section-1" } },
],
},
error: /Multiple matches/,
},
{
name: "throws when multiple files match",
itemOpts: {
files: [
{ id: "id1", name: "password", section: { id: "section-1" } },
{ id: "id2", name: "password", section: { id: "section-1" } },
],
},
error: /Multiple matches/,
},
{
name: "throws when both a field and a file match",
itemOpts: {
fields: [
{ label: "password", value: "v", section: { id: "section-1" } },
],
files: [
{ id: "fid", name: "password", section: { id: "section-1" } },
],
},
error: /Both a field and a file match/,
},
{
name: "throws when field has no value",
itemOpts: {
fields: [
{ label: "password", value: null, section: { id: "section-1" } },
],
},
error: /has no value/,
},
])("$name", ({ itemOpts, error }) => {
expect(() => find(itemOpts, ["section-1"])).toThrow(error);
});
});
describe("when no section filter (sectionIds.length === 0)", () => {
const sectionIds: string[] = [];
it.each<{
name: string;
itemOpts: { fields?: TestField[]; files?: TestFile[] };
expected: { fieldValue?: string; fileId?: string };
}>([
{
name: "returns field value when one field has no section and matches query",
itemOpts: {
fields: [{ label: "password", value: "secret", section: undefined }],
},
expected: { fieldValue: "secret" },
},
{
name: "returns file id when one file has no section and matches query",
itemOpts: {
files: [{ id: "file-id", name: "password", section: undefined }],
},
expected: { fileId: "file-id" },
},
{
name: "returns field value from fallback (any section) when no field with no section matches",
itemOpts: {
fields: [
{ label: "other", value: "x", section: undefined },
{
label: "password",
value: "from-any-section",
section: { id: "sec" },
},
],
},
expected: { fieldValue: "from-any-section" },
},
{
name: "returns file id from fallback (any section) when no file with no section matches",
itemOpts: {
files: [
{ id: "other", name: "x", section: undefined },
{ id: "file-any", name: "password", section: { id: "sec" } },
],
},
expected: { fileId: "file-any" },
},
{
name: "returns empty object when no match",
itemOpts: {
fields: [{ label: "other", value: "x", section: undefined }],
files: [],
},
expected: {},
},
])("$name", ({ itemOpts, expected }) => {
expect(find(itemOpts, sectionIds)).toEqual(expected);
});
it.each<{
name: string;
itemOpts: { fields?: TestField[]; files?: TestFile[] };
error: RegExp;
}>([
{
name: "throws when multiple fields with no section match",
itemOpts: {
fields: [
{ label: "password", value: "a", section: undefined },
{ label: "password", value: "b", section: undefined },
],
},
error: /Multiple matches/,
},
{
name: "throws when multiple files with no section match",
itemOpts: {
files: [
{ id: "1", name: "password", section: undefined },
{ id: "2", name: "password", section: undefined },
],
},
error: /Multiple matches/,
},
{
name: "throws when both field and file match",
itemOpts: {
fields: [{ label: "password", value: "value", section: undefined }],
files: [{ id: "fid", name: "password", section: undefined }],
},
error: /Both a field and a file match/,
},
])("$name", ({ itemOpts, error }) => {
expect(() => find(itemOpts, sectionIds)).toThrow(error);
});
});
});
describe("findSectionIdsByQuery", () => {
it("throws when sections is empty", () => {
expect(() => findSectionIdsByQuery([], "section-1")).toThrow(
/Item has no sections; cannot resolve section "section-1"/,
);
});
it("throws when sections is null/undefined", () => {
expect(() =>
findSectionIdsByQuery(undefined as unknown as FullItem["sections"], "x"),
).toThrow(/Item has no sections; cannot resolve section "x"/);
});
it("throws when section query matches no section", () => {
const sections = [{ id: "sec-1", label: "Other" }];
expect(() =>
findSectionIdsByQuery(sections as FullItem["sections"], "nonexistent"),
).toThrow(/No section matching "nonexistent" found in specified item/);
});
it("returns section id when section matches by label", () => {
const sections = [{ id: "sec-1", label: "My Section" }];
expect(
findSectionIdsByQuery(sections as FullItem["sections"], "My Section"),
).toEqual(["sec-1"]);
});
it("throws when section query matches no section", () => {
const sections = [{ id: "sec-1", label: "Other" }];
expect(() =>
findSectionIdsByQuery(sections as FullItem["sections"], "nonexistent"),
).toThrow(/No section matching "nonexistent" found in specified item/);
});
it("returns multiple ids when multiple sections match", () => {
const sections = [
{ id: "sec-1", label: "A" },
{ id: "sec-2", label: "A" },
];
expect(
findSectionIdsByQuery(sections as FullItem["sections"], "A"),
).toEqual(["sec-1", "sec-2"]);
});
});
describe("parseOpRef", () => {
it("parses 3-segment ref (vault/item/field)", () => {
expect(parseOpRef("op://vault/item/field")).toEqual({
vault: "vault",
item: "item",
field: "field",
section: undefined,
});
});
it("parses 4-segment ref (vault/item/section/field)", () => {
expect(parseOpRef("op://vault/item/MySection/password")).toEqual({
vault: "vault",
item: "item",
section: "MySection",
field: "password",
});
});
it("decodes URI-encoded segments", () => {
expect(parseOpRef("op://my%20vault/my%20item/field")).toEqual({
vault: "my vault",
item: "my item",
field: "field",
section: undefined,
});
});
it("throws when ref does not start with op://", () => {
expect(() => parseOpRef("invalid-ref")).toThrow(
/Invalid op reference: invalid-ref/,
);
});
it("throws when segment count is invalid", () => {
expect(() => parseOpRef("op://vault/item")).toThrow(
/use op:\/\/<vault>\/<item>\/<field>/,
);
expect(() => parseOpRef("op://a/b/c/d/e")).toThrow(
/use op:\/\/<vault>\/<item>\/<field>/,
);
});
it("throws when vault or item or field is empty", () => {
expect(() => parseOpRef("op:///item/field")).toThrow(/vault is required/);
expect(() => parseOpRef("op://vault//field")).toThrow(/item is required/);
expect(() => parseOpRef("op://vault/item/")).toThrow(/field is required/);
});
it("throws when 4-segment ref has empty section", () => {
expect(() => parseOpRef("op://vault/item//field")).toThrow(
/section is required when using 4 path segments/,
);
});
it("throws when last segment is empty (trailing slash)", () => {
expect(() => parseOpRef("op://vault/item/field/")).toThrow(
/field is required/,
);
});
});
describe("getEnvVarNamesWithSecretRefs", () => {
it("returns only env var names whose value is a string starting with op://", () => {
process.env.OP_REF = "op://vault/item/field";
process.env.NOT_OP_REF = "https://example.com";
process.env.EMPTY_REF = "";
process.env.OP_REF_OTHER = "op://other/vault/item/secret";
const result = getEnvVarNamesWithSecretRefs();
expect(result).toContain("OP_REF");
expect(result).toContain("OP_REF_OTHER");
expect(result).not.toContain("NOT_OP_REF");
expect(result).not.toContain("EMPTY_REF");
});
});

View File

@@ -1,7 +1,6 @@
import * as core from "@actions/core";
import { read } from "@1password/op-js";
import { createClient, Secrets } from "@1password/sdk";
import { OnePasswordConnect, FullItem, OPConnect } from "@1password/connect";
import * as exec from "@actions/exec";
import { read, setClientInfo, semverToInt } from "@1password/op-js";
import { version } from "../package.json";
import {
authErr,
@@ -11,317 +10,6 @@ import {
envManagedVariables,
} from "./constants";
// #region Op ref parsing
interface ParsedOpRef {
vault: string;
item: string;
section: string | undefined;
field: string;
}
export const parseOpRef = (ref: string): ParsedOpRef => {
// Safety check: refs are validated by validateSecretRefs before this runs
// this guards against parseOpRef being called directly with invalid input
if (!ref.startsWith("op://")) {
throw new Error(`Invalid op reference: ${ref}`);
}
const segments = ref
.slice("op://".length)
.split("/")
.map((s) => decodeURIComponent(s));
if (segments.length < 3 || segments.length > 4) {
throw new Error(
`Invalid op reference: use op://<vault>/<item>/<field> or op://<vault>/<item>/<section>/<field>. Got: ${ref}`,
);
}
const vault = segments[0] ?? "";
if (!vault) {
throw new Error(`Invalid op reference: vault is required`);
}
const item = segments[1] ?? "";
if (!item) {
throw new Error(`Invalid op reference: item is required`);
}
// Last segment is always the field
const field = segments[segments.length - 1] ?? "";
if (!field) {
throw new Error(`Invalid op reference: field is required`);
}
// Second to last segment is the section if it exists
let section: string | undefined;
if (segments.length === 4) {
section = segments[2];
if (!section) {
throw new Error(
`Invalid op reference: section is required when using 4 path segments`,
);
}
}
return {
vault,
item,
field,
section,
};
};
// #endregion
// #region Connect item resolution
const getSecretFromConnectItem = async (
client: OPConnect,
item: FullItem,
parsed: ParsedOpRef,
): Promise<string> => {
const sectionIds = parsed.section
? findSectionIdsByQuery(item.sections, parsed.section)
: [];
const { fieldValue, fileId } = findMatchingFieldAndFile(
item,
parsed.field,
sectionIds,
);
if (fieldValue !== undefined) {
return fieldValue;
}
if (fileId) {
return getFileContentWithRetry(client, parsed.vault, parsed.item, fileId);
}
if (parsed.section) {
throw new Error(
`could not find field or file ${parsed.field} in section ${parsed.section} on item ${parsed.item} in vault ${parsed.vault}`,
);
}
throw new Error(
`could not find field or file ${parsed.field} on item ${parsed.item} in vault ${parsed.vault}`,
);
};
const getFileContentWithRetry = async (
client: OPConnect,
vaultId: string,
itemId: string,
fileId: string,
): Promise<string> => {
const maxAttempts = 3;
const retryDelayMs = 2000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await client.getFileContent(vaultId, itemId, fileId);
} catch (err) {
// Retry on 503 errors as this can happen on multiple secret fetches
const is503 =
err !== null &&
typeof err === "object" &&
(err as Record<string, unknown>).statusCode === 503;
if (is503 && attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, retryDelayMs));
continue;
}
throw err;
}
}
return "";
};
export const findSectionIdsByQuery = (
sections: FullItem["sections"],
sectionQuery: string | undefined,
): string[] => {
// If no sections were returned with the item throw an error
if (!sections || sections.length === 0) {
throw new Error(
`Item has no sections; cannot resolve section "${sectionQuery}"`,
);
}
const ids = sections
.filter((s) => s.id === sectionQuery || s.label === sectionQuery)
.flatMap((s) => (s.id ? [s.id] : []));
// If no sections were found with the given query throw an error
if (ids.length === 0) {
throw new Error(
`No section matching "${sectionQuery}" found in specified item`,
);
}
return ids;
};
export const findMatchingFieldAndFile = (
item: FullItem,
fieldOrFileQuery: string,
sectionIds: string[],
): { fieldValue?: string; fileId?: string } => {
// Get the fields/files from the item and check if the ref has a section filter
const fields = item.fields ?? [];
const files = item.files ?? [];
const sectionFilter = sectionIds.length > 0;
const fieldMatchesQuery = (f: (typeof fields)[0]) =>
f.id === fieldOrFileQuery || f.label === fieldOrFileQuery;
const fileMatchesQuery = (f: (typeof files)[0]) =>
f.id === fieldOrFileQuery || f.name === fieldOrFileQuery;
let matchedField: (typeof fields)[0] | undefined;
let matchedFile: (typeof files)[0] | undefined;
if (sectionFilter) {
// If the ref has a section filter only accept matches inside the referenced sections
const matchingFields = fields.filter((f) => {
const sectionId = f.section?.id;
const inRefSections =
sectionId !== null &&
sectionId !== undefined &&
sectionIds.includes(sectionId);
return fieldMatchesQuery(f) && inRefSections;
});
matchedField = findSingleMatch(matchingFields);
const matchingFiles = files.filter((f) => {
const sectionId = f.section?.id;
const inRefSections =
sectionId !== null &&
sectionId !== undefined &&
sectionIds.includes(sectionId);
return fileMatchesQuery(f) && inRefSections;
});
matchedFile = findSingleMatch(matchingFiles);
} else {
// If the ref has no section filter search for matches with no section
const matchingFields = fields.filter((f) => {
const hasNoSection =
f.section?.id === null || f.section?.id === undefined;
return fieldMatchesQuery(f) && hasNoSection;
});
matchedField = findSingleMatch(matchingFields);
// If no matches were found with no section, search for matches in any section
if (!matchedField) {
const matchingFieldsInAnySection = fields.filter(fieldMatchesQuery);
matchedField = findSingleMatch(matchingFieldsInAnySection);
}
const matchingFiles = files.filter((f) => {
const hasNoSection =
f.section?.id === null || f.section?.id === undefined;
return fileMatchesQuery(f) && hasNoSection;
});
matchedFile = findSingleMatch(matchingFiles);
if (!matchedFile) {
const matchingFilesInAnySection = files.filter(fileMatchesQuery);
matchedFile = findSingleMatch(matchingFilesInAnySection);
}
}
if (matchedField && matchedFile) {
throw new Error(
`Both a field and a file match "${fieldOrFileQuery}". Rename one or use the ID in your op:// reference.`,
);
}
if (matchedField) {
if (matchedField.value === undefined || matchedField.value === null) {
throw new Error(
`field ${fieldOrFileQuery} has no value in specified item`,
);
}
return { fieldValue: matchedField.value };
}
if (matchedFile?.id) {
return { fileId: matchedFile.id };
}
return {};
};
const findSingleMatch = <T>(matches: T[]): T | undefined => {
if (matches.length > 1) {
throw new Error(
"Multiple matches found. Rename one or use an ID in your op:// reference.",
);
}
return matches[0];
};
const createConnectClient = (host: string, token: string): OPConnect => {
try {
return OnePasswordConnect({
// eslint-disable-next-line @typescript-eslint/naming-convention
serverURL: host,
token,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Connect authentication failed: ${message}`);
}
};
// #endregion
// #region Shared helpers and auth
export const getEnvVarNamesWithSecretRefs = (): string[] =>
Object.keys(process.env).filter(
(key) =>
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://"),
);
const validateSecretRefs = (envNames: string[]): void => {
const invalid: { name: string; message: string }[] = [];
for (const envName of envNames) {
const ref = process.env[envName];
if (!ref) {
continue;
}
try {
Secrets.validateSecretReference(ref);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
invalid.push({ name: envName, message });
}
}
// Throw an error if any secret references are invalid
if (invalid.length > 0) {
const details = invalid
.map(({ name, message }) => `${name}: ${message}`)
.join("; ");
throw new Error(`Invalid secret reference(s): ${details}`);
}
};
const setResolvedSecret = (
envName: string,
secretValue: string,
shouldExportEnv: boolean,
): void => {
core.info(`Populating variable: ${envName}`);
if (shouldExportEnv) {
core.exportVariable(envName, secretValue);
} else {
core.setOutput(envName, secretValue);
}
if (secretValue) {
core.setSecret(secretValue);
}
};
export const validateAuth = (): void => {
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
const isServiceAccount = process.env[envServiceAccountToken];
@@ -345,17 +33,50 @@ 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 === null || secretValue === undefined) {
if (!secretValue) {
return;
}
setResolvedSecret(envName, secretValue, shouldExportEnv);
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 => {
@@ -368,138 +89,3 @@ export const unsetPrevious = (): void => {
}
}
};
const fetchVaultId = async (
client: OPConnect,
vaultQuery: string,
ref: string,
vaultIdCache: Map<string, string>,
): Promise<string> => {
// Check if the vault ID is already cached to avoid unnecessary API calls
const cached = vaultIdCache.get(vaultQuery);
if (cached !== undefined) {
return cached;
}
const vault = await client.getVault(vaultQuery);
if (!vault.id) {
throw new Error(
`Could not find valid vault "${vaultQuery}" for ref "${ref}"`,
);
}
vaultIdCache.set(vaultQuery, vault.id);
return vault.id;
};
// #endregion
// #region Load secrets
// Connect loads secrets via the Connect JS SDK
const loadSecretsViaConnect = async (
shouldExportEnv: boolean,
): Promise<void> => {
const envs = getEnvVarNamesWithSecretRefs();
if (envs.length === 0) {
return;
}
validateSecretRefs(envs);
const host = process.env[envConnectHost];
const token = process.env[envConnectToken];
if (!host || !token) {
throw new Error(authErr);
}
const client = createConnectClient(host, token);
const vaultIdCache = new Map<string, string>();
for (const envName of envs) {
const ref = process.env[envName];
if (!ref) {
continue;
}
try {
// Parse the op ref and get the item from the Connect SDK
const parsed = parseOpRef(ref);
const vaultId = await fetchVaultId(
client,
parsed.vault,
ref,
vaultIdCache,
);
const item = await client.getItem(vaultId, parsed.item);
// Get the secret value from the item as Connect returns a full item object
const secretValue = await getSecretFromConnectItem(client, item, parsed);
setResolvedSecret(envName, secretValue, shouldExportEnv);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to load ref "${ref}": ${msg}`);
}
}
if (shouldExportEnv) {
core.exportVariable(envManagedVariables, envs.join());
}
};
// Service Account loads secrets via the 1Password SDK
const loadSecretsViaServiceAccount = async (
shouldExportEnv: boolean,
): Promise<void> => {
const envs = getEnvVarNamesWithSecretRefs();
if (envs.length === 0) {
return;
}
validateSecretRefs(envs);
const token = process.env[envServiceAccountToken];
if (!token) {
throw new Error(authErr);
}
// Authenticate with the 1Password SDK
let client;
try {
client = await createClient({
auth: token,
integrationName: "1Password GitHub Action",
integrationVersion: version,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Service account authentication failed: ${message}`);
}
for (const envName of envs) {
const ref = process.env[envName];
if (!ref) {
continue;
}
// Resolve the secret value using the 1Password SDK
// and make it available either as step outputs or as environment variables
const secretValue = await client.secrets.resolve(ref);
setResolvedSecret(envName, secretValue, shouldExportEnv);
}
if (shouldExportEnv) {
core.exportVariable(envManagedVariables, envs.join());
}
};
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
if (isConnect) {
await loadSecretsViaConnect(shouldExportEnv);
return;
}
await loadSecretsViaServiceAccount(shouldExportEnv);
};
// #endregion

View File

@@ -9,11 +9,11 @@ assert_env_equals() {
fi
}
readonly SECRET="RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
readonly FILE_SECRET_CONTENT="This is a test"
readonly DOUBLE_SECTION_SECRET_CONTENT="test-password"
assert_env_equals "SECRET" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
MULTILINE_SECRET="$(cat << EOF
assert_env_equals "SECRET_IN_SECTION" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
assert_env_equals "MULTILINE_SECRET" "$(cat << EOF
-----BEGIN PRIVATE KEY-----
RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLApXaGls
ZSB3ZSBkZWVwbHkgYXBwcmVjaWF0ZSB5b3VyIHZp
@@ -28,18 +28,3 @@ IApTbyBwbGVhc2UgZG9uJ3QgcmVwb3J0IGl0IQo=
-----END PRIVATE KEY-----
EOF
)"
readonly MULTILINE_SECRET
assert_env_equals "SECRET" "${SECRET}"
assert_env_equals "FILE_SECRET" "${SECRET}"
assert_env_equals "SECRET_IN_SECTION" "${SECRET}"
assert_env_equals "FILE_SECRET_IN_SECTION" "${SECRET}"
assert_env_equals "MULTILINE_SECRET" "${MULTILINE_SECRET}"
assert_env_equals "FILE_MULTILINE_SECRET" "${MULTILINE_SECRET}"
assert_env_equals "SECRET_WITH_FILE" "${FILE_SECRET_CONTENT}"
assert_env_equals "SECRET_WITH_FILE_IN_SECTION" "${FILE_SECRET_CONTENT}"
assert_env_equals "DOUBLE_SECTION_SECRET" "${DOUBLE_SECTION_SECRET_CONTENT}"

View File

@@ -10,14 +10,5 @@ assert_env_unset() {
}
assert_env_unset "SECRET"
assert_env_unset "FILE_SECRET"
assert_env_unset "SECRET_IN_SECTION"
assert_env_unset "FILE_SECRET_IN_SECTION"
assert_env_unset "MULTILINE_SECRET"
assert_env_unset "FILE_MULTILINE_SECRET"
assert_env_unset "SECRET_WITH_FILE"
assert_env_unset "SECRET_WITH_FILE_IN_SECTION"
assert_env_unset "DOUBLE_SECTION_SECRET"

View File

@@ -1,7 +0,0 @@
#!/bin/bash
set -e
if [ "$STEP_OUTCOME" != "failure" ]; then
echo "Expected action to fail on invalid ref, got: $STEP_OUTCOME"
exit 1
fi
echo "Action correctly failed on invalid ref"