Compare commits

..

1 Commits

Author SHA1 Message Date
Eddy Filip
61def4eb0f Add item create command workaround
Some checks failed
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
This content will easily let the users know the alternate way in which they have to create items in GitHub pipelines.
2023-04-27 12:40:01 +02:00
32 changed files with 10724 additions and 64130 deletions

View File

@@ -1,316 +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 }}, export-env=${{ matrix.export-env }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
export-env: [true, false]
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build actions
run: npm run build:all
- name: Generate .env.tpl
shell: bash
run: |
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:
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:
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, export-env=${{ matrix.export-env }})
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
export-env: [true, false]
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build actions
run: npm run build:all
- name: Generate .env.tpl
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:
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:
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

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 }}

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

@@ -0,0 +1,136 @@
on: push
name: Run acceptance tests
jobs:
test-with-output-secrets:
strategy:
matrix:
os: [ ubuntu-latest, macos-latest ]
auth: [ connect, service-account ]
exclude:
- os: macos-latest
auth: connect
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Launch 1Password Connect instance
if: ${{ matrix.auth == 'connect' }}
env:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
run: |
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
- name: Configure Service account
if: ${{ matrix.auth == 'service-account' }}
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Configure 1Password Connect
if: ${{ matrix.auth == 'connect' }}
uses: ./configure # 1password/load-secrets-action/configure@<version>
with:
connect-host: localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./ # 1password/load-secrets-action@<version>
with:
export-env: false
env:
SECRET: op://acceptance-tests/test-secret/password
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
- name: Assert test secret values
env:
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
run: ./tests/assert-env-set.sh
test-with-export-env:
strategy:
matrix:
os: [ ubuntu-latest, macos-latest ]
auth: [ connect, service-account ]
exclude:
- os: macos-latest
auth: connect
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Launch 1Password Connect instance
if: ${{ matrix.auth == 'connect' }}
env:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
run: |
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
- name: Configure Service account
if: ${{ matrix.auth == 'service-account' }}
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Configure 1Password Connect
if: ${{ matrix.auth == 'connect' }}
uses: ./configure # 1password/load-secrets-action/configure@<version>
with:
connect-host: http://localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./ # 1password/load-secrets-action@<version>
env:
SECRET: op://acceptance-tests/test-secret/password
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
- name: Assert test secret values
run: ./tests/assert-env-set.sh
- name: Remove secrets
uses: ./ # 1password/load-secrets-action@<version>
with:
unset-previous: true
- name: Assert removed secrets
run: ./tests/assert-env-unset.sh
test-references-with-ids:
strategy:
matrix:
os: [ ubuntu-latest, macos-latest ]
auth: [ connect, service-account ]
exclude:
- os: macos-latest
auth: connect
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Launch 1Password Connect instance
if: ${{ matrix.auth == 'connect' }}
env:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
run: |
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
- name: Configure Service account
if: ${{ matrix.auth == 'service-account' }}
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Configure 1Password Connect
if: ${{ matrix.auth == 'connect' }}
uses: ./configure # 1password/load-secrets-action/configure@<version>
with:
connect-host: http://localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./ # 1password/load-secrets-action@<version>
with:
export-env: false
env:
SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
SECRET_IN_SECTION: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/Section_tco6nsqycj6jcbyx63h5isxcny/doxu3mhkozcznnk5vjrkpdqayy
MULTILINE_SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
- name: Assert test secret values
env:
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
run: ./tests/assert-env-set.sh

2
.gitignore vendored
View File

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

View File

@@ -1,54 +0,0 @@
# Contributing
Thank you for your interest in contributing to the 1Password load-secrets-action project 👋! Before you start, please take a moment to read through this guide to understand our contribution process.
## Testing
Unit tests can be run with `npm run test`.
After following the steps below for signing commits, you can test against your PR with these steps:
1. Create or use an existing repo to run the `load-secrets` GitHub Action.
2. In a workflow yaml file that uses the GitHub Action, modify the `uses: 1Password/load-secrets-action` line to be
```yaml
uses: 1Password/load-secrets-action@<branch-name>
```
OR
```yaml
uses: 1Password/load-secrets-action@<commit-hash>
```
3. Trigger the action, which now includes your changes.
## Documentation Updates
If applicable, update the [README.md](./README.md) to reflect any changes introduced by the new code.
## Sign your commits
To get your PR merged, we require you to sign your commits.
### Sign commits with 1Password
You can also sign commits using 1Password, which lets you sign commits with biometrics without the signing key leaving the local 1Password process.
Learn how to use [1Password to sign your commits](https://developer.1password.com/docs/ssh/git-commit-signing/).
### Sign commits with ssh-agent
Follow the steps below to set up commit signing with `ssh-agent`:
1. [Generate an SSH key and add it to ssh-agent](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent)
2. [Add the SSH key to your GitHub account](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account)
3. [Configure git to use your SSH key for commits signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key#telling-git-about-your-ssh-key)
### Sign commits with gpg
Follow the steps below to set up commit signing with `gpg`:
1. [Generate a GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key)
2. [Add the GPG key to your GitHub account](https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account)
3. [Configure git to use your GPG key for commits signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key#telling-git-about-your-gpg-key)

457
README.md
View File

@@ -1,29 +1,38 @@
<!-- Image sourced from https://blog.1password.com/1password-service-accounts/ -->
<img alt="" role="img" src="https://blog.1password.com/posts/2023/1password-service-accounts/header.png"/>
# Load Secrets from 1Password - GitHub Action
<div align="center">
<h1>Load Secrets from 1Password - GitHub Action</h1>
<p>Provide the secrets your GitHub runner needs from 1Password.</p>
<a href="https://developer.1password.com/docs/ci-cd/github-actions">
<img alt="Get started" src="https://user-images.githubusercontent.com/45081667/226940040-16d3684b-60f4-4d95-adb2-5757a8f1bc15.png" height="37"/>
</a>
</div>
---
`load-secrets-action` loads secrets from 1Password into GitHub Actions using [Service Accounts](https://developer.1password.com/docs/service-accounts) or [1Password Connect](https://developer.1password.com/docs/connect).
This action loads secrets from 1Password into GitHub Actions using [1Password Connect](https://developer.1password.com/docs/connect) or a [Service Account <sup>[BETA]</sup>](https://developer.1password.com/docs/service-accounts).
Specify in your workflow YAML file which secrets from 1Password should be loaded into your job, and the action will make them available as environment variables for the next steps.
Read more on the [1Password Developer Portal](https://developer.1password.com/docs/ci-cd/github-actions).
## 🪄 See it in action!
## Requirements
[![Using 1Password Service Accounts with GitHub Actions - showcase](https://img.youtube.com/vi/kVBl5iQYgSA/maxresdefault.jpg)](https://www.youtube.com/watch?v=kVBl5iQYgSA "Using 1Password Service Accounts with GitHub Actions")
Before you get started, you'll need to:
## ✨ Quickstart
- [Deploy 1Password Connect](/docs/connect/get-started#step-2-deploy-1password-connect-server) in your infrastructure.
- Set the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` environment variables to your Connect instance's credentials, so it'll be used to load secrets.
### Export secrets as a step's output (recommended)
_Supported runners_: You can run the action on Mac and Linux runners. Windows is currently not supported.
## Usage
You can configure the action to use your 1Password Connect instance.
If you provide `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` variables, the Connect instance will be used to load secrets. Make sure [1Password Connect](https://support.1password.com/secrets-automation/#step-2-deploy-a-1password-connect-server) is deployed in your infrastructure.
If you provide `OP_SERVICE_ACCOUNT_TOKEN` variable, the service account will be used to load secrets.
**_Note_**: If all environment variables have been set, the Connect credentials will take precedence over the provided service account token. You must unset the Connect environment variables to ensure the action uses the service account token.
There are two ways that secrets can be loaded:
- [use the secrets from the action's ouput](#use-secrets-from-the-actions-output)
- [export secrets as environment variables](#export-secrets-as-environment-variables)
### Use secrets from the action's output
This method allows for you to use the loaded secrets as an output from the step: `steps.step-id.outputs.secret-name`. You will need to set an id for the step that uses this action to be able to access its outputs. For more details, , see [`outputs.<output_id>`](https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputsoutput_id).
```yml
on: push
@@ -31,22 +40,25 @@ 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
id: op-load-secret
uses: 1password/load-secrets-action@v1
with:
export-env: false
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
OP_CONNECT_HOST: <Your Connect instance URL>
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_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 }}"'
run: echo "Secret: ${{ steps.op-load-secret.outputs.SECRET }}"
# Prints: Secret: ***
```
### Export secrets as env variables
<details>
<summary><b>Usage example with Service Accounts <sup>BETA</sup></b></summary>
```yml
on: push
@@ -54,46 +66,403 @@ jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Load secret
uses: 1password/load-secrets-action@v3
id: op-load-secret
uses: 1password/load-secrets-action@v1
with:
export-env: false
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
SECRET: op://app-cicd/hello-world/secret
- name: Print masked secret
run: echo "Secret: ${{ steps.op-load-secret.outputs.SECRET }}"
# Prints: Secret: ***
```
</details>
<details>
<summary><b>Longer usage example</b></summary>
```yml
on: push
name: Deploy app
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
# Persist the 1Password Connect URL for next steps. You can also persist
# the Connect token using input `connect-token`, but keep in mind that
# this will grant all steps of the job access to the token.
connect-host: https://1password.acme.com
- name: Load Docker credentials
id: load-docker-credentials
uses: 1password/load-secrets-action@v1
with:
export-env: false
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
DOCKERHUB_USERNAME: op://app-cicd/docker/username
DOCKERHUB_TOKEN: op://app-cicd/docker/token
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_USERNAME }}
password: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: acme/app:latest
```
</details>
<details>
<summary><b>Longer usage example with Service Accounts <sup>BETA</sup></b></summary>
```yml
on: push
name: Deploy app
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
# Persist the 1Password Service Account token. This will grant
# all steps of the job access to the token.
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Load Docker credentials
id: load-docker-credentials
uses: 1password/load-secrets-action@v1
with:
export-env: false
env:
DOCKERHUB_USERNAME: op://app-cicd/docker/username
DOCKERHUB_TOKEN: op://app-cicd/docker/token
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_USERNAME }}
password: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: acme/app:latest
```
</details>
### Export secrets as environment variables
This method, allows the action to access the loaded secrets as environment variables. These environment variables are accessible at a job level.
```yml
on: push
jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Load secret
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
SECRET: op://app-cicd/hello-world/secret
OP_ENV_FILE: "./path/to/.env.tpl" # see tests/.env.tpl for example
- name: Print masked secret
run: 'echo "Secret: $SECRET"'
run: echo "Secret: $SECRET"
# Prints: Secret: ***
```
### 🔑 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.
<details>
<summary><b>Usage example with Service Accounts <sup>BETA</sup></b></summary>
```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
on: push
jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Load secret
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
env:
OP_CONNECT_HOST: <Your Connect instance URL>
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
SECRET: op://app-cicd/hello-world/secret
- name: Print masked secret
run: echo "Secret: $SECRET"
# Prints: Secret: ***
```
For more details on secret reference syntax, see the [1Password documentation](https://developer.1password.com/docs/cli/secret-reference-syntax/#ssh-format-parameter).
</details>
## 💙 Community & Support
<details>
<summary><b>Longer usage example</b></summary>
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
- Join the [Developer Slack workspace](https://developer.1password.com/joinslack).
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
```yml
on: push
name: Deploy app
## 🔐 Security
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
# Persist the 1Password Connect URL for next steps. You can also persist
# the Connect token using input `connect-token`, but keep in mind that
# this will grant all steps of the job access to the token.
connect-host: https://1password.acme.com
- name: Load Docker credentials
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
DOCKERHUB_USERNAME: op://app-cicd/docker/username
DOCKERHUB_TOKEN: op://app-cicd/docker/token
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Print environment variables with masked secrets
run: printenv
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: acme/app:latest
- name: Load AWS credentials
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
# Remove local copies of the Docker credentials, which aren't needed anymore
unset-previous: true
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
AWS_ACCESS_KEY_ID: op://app-cicd/aws/access-key-id
AWS_SECRET_ACCESS_KEY: op://app-cicd/aws/secret-access-key
- name: Deploy app
# This script expects AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set.
# This happened using secret references in the preceding lines.
run: ./deploy.sh
```
</details>
<details>
<summary><b>Longer usage example with Service Accounts <sup>BETA</sup></b></summary>
```yml
on: push
name: Deploy app
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
# Persist the 1Password Service Account token. This will grant
# all steps of the job access to the token.
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Load Docker credentials
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
env:
DOCKERHUB_USERNAME: op://app-cicd/docker/username
DOCKERHUB_TOKEN: op://app-cicd/docker/token
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Print environment variables with masked secrets
run: printenv
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: acme/app:latest
- name: Load AWS credentials
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
# Remove local copies of the Docker credentials, which aren't needed anymore
unset-previous: true
env:
AWS_ACCESS_KEY_ID: op://app-cicd/aws/access-key-id
AWS_SECRET_ACCESS_KEY: op://app-cicd/aws/secret-access-key
- name: Deploy app
# This script expects AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set.
# This happened using secret references in the preceding lines.
run: ./deploy.sh
```
</details>
## Action Inputs
| Name | Default | Description |
| ---------------- | ------- | ---------------------------------------------------------------------------------- |
| `export-env` | `true` | Export the loaded secrets as environment variables |
| `unset-previous` | `false` | Whether to unset environment variables populated by 1Password in earlier job steps |
## Secrets Reference Syntax
To specify which secret should be loaded into which environment variable, the action will look for `op://` reference URIs in environment variables, and replace those with the actual secret values.
These reference URIs have the following syntax:
> `op://<vault>/<item>[/<section>]/<field>`
So for example, the reference URI `op://app-cicd/aws/secret-access-key` would be interpreted as:
- **Vault:** `app-cicd`
- **Item:** `aws`
- **Section:** default section
- **Field:** `secret-access-key`
## Masking
Similar to regular GitHub repository secrets, fields from 1Password will automatically be masked from the GitHub Actions logs too.
So if one of these values accidentally gets printed, it'll get replaced with `***`.
## 1Password Configuration
To use the action with Connect, you need to have a [1Password Connect](https://support.1password.com/secrets-automation/#step-1-set-up-a-secrets-automation-workflow) instance deployed somewhere.
To configure the action with your Connect host and token, set the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` environment variables.
To configure the action with your service account token <sup>BETA</sup>, set the `OP_SERVICE_ACCOUNT_TOKEN` environment variable.
If you're using the `load-secrets` action more than once in a single job, you can use the `configure` action to avoid duplicate configuration:
```yml
on: push
jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
connect-host: <Your Connect instance URL>
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secret
uses: 1password/load-secrets-action@v1
env:
SECRET: op://app-cicd/hello-world/secret
```
### `configure` Action Inputs
| Name | Environment variable | Description |
| ----------------------- | -------------------------- | -------------------------------------------------------- |
| `connect-host` | `OP_CONNECT_HOST` | Your 1Password Connect instance URL |
| `connect-token` | `OP_CONNECT_TOKEN` | Token to authenticate to your 1Password Connect instance |
| `service-account-token` | `OP_SERVICE_ACCOUNT_TOKEN` | Your 1Password service account token |
## Supported Runners
You can run the action on Linux and macOS runners. Windows is currently not supported.
## Warnings
If you're using the CLI in your GitHub pipelines and you want to create items with it, the following command will fail:
```
op item create --category=login --title='My Example Item' --vault='Test' \
--url https://www.acme.com/login \
--generate-password=20,letters,digits \
username=jane@acme.com \
'Test Field 1=my test secret' \
'Test Section 1.Test Field2[text]=Jane Doe' \
'Test Section 1.Test Field3[date]=1995-02-23' \
'Test Section 2.Test Field4[text]='$myNotes
```
This is caused by the fact that the environment in these pipelines is in piped mode, which triggers the CLI's pipe detection to expect a piped input.
To be able to create items in such environments, do the following steps:
1. Get the template of the item category you want:
```sh
op item template get --out-file=new-item.json <category>
```
2. Edit [the template](https://developer.1password.com/docs/cli/item-template-json) to add your information.
3. Pipe the item content to the command:
```sh
cat new-item.json | op item create --vault='Test'
```
## Security
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 through [BugCrowd](https://bugcrowd.com/agilebits).
For information about our security practices, visit the [1Password Security homepage](https://1password.com/security).
## Getting help
If you find yourself stuck, visit our [**Support Page**](https://support.1password.com/) for help.

View File

@@ -10,11 +10,7 @@ inputs:
default: "false"
export-env:
description: Export the secrets as environment variables
default: "false"
# Backward-compatible no-op input (intentionally ignored)
version:
description: "(Deprecated) No longer used. Kept for backwards compatibility."
default: "latest"
default: "true"
runs:
using: "node20"
using: "node16"
main: "dist/index.js"

View File

@@ -11,16 +11,7 @@ const jestConfig = {
testEnvironment: "node",
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
transform: {
".ts": [
"ts-jest",
{
// Note: We shouldn't need to include `isolatedModules` here because it's a deprecated config option in TS 5,
// but setting it to `true` fixes the `ESM syntax is not allowed in a CommonJS module when
// 'verbatimModuleSyntax' is enabled` error that we're seeing when running our Jest tests.
isolatedModules: true,
useESM: true,
},
],
".ts": ["ts-jest"],
},
verbose: true,
};

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();

31179
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
```

146
entrypoint.sh Executable file
View File

@@ -0,0 +1,146 @@
#!/bin/bash
# shellcheck disable=SC2046,SC2001,SC2086
set -e
# Pass User-Agent Inforomation to the 1Password CLI
export OP_INTEGRATION_NAME="1Password GitHub Action"
export OP_INTEGRATION_ID="GHA"
export OP_INTEGRATION_BUILDNUMBER="1010001"
readonly CONNECT="CONNECT"
readonly SERVICE_ACCOUNT="SERVICE_ACCOUNT"
auth_type=$CONNECT
managed_variables_var="OP_MANAGED_VARIABLES"
IFS=','
if [[ "$OP_CONNECT_HOST" != "http://"* ]] && [[ "$OP_CONNECT_HOST" != "https://"* ]]; then
export OP_CONNECT_HOST="http://"$OP_CONNECT_HOST
fi
# Unset all secrets managed by 1Password if `unset-previous` is set.
unset_prev_secrets() {
if [ "$INPUT_UNSET_PREVIOUS" == "true" ]; then
echo "Unsetting previous values..."
# Find environment variables that are managed by 1Password.
for env_var in "${managed_variables[@]}"; do
echo "Unsetting $env_var"
unset $env_var
echo "$env_var=" >> $GITHUB_ENV
# Keep the masks, just in case.
done
managed_variables=()
fi
}
# Install op-cli
install_op_cli() {
OP_INSTALL_DIR="$(mktemp -d)"
if [[ ! -d "$OP_INSTALL_DIR" ]]; then
echo "Install dir $OP_INSTALL_DIR not found"
exit 1
fi
export OP_INSTALL_DIR
echo "::debug::OP_INSTALL_DIR: ${OP_INSTALL_DIR}"
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
curl -sSfLo op.zip "https://cache.agilebits.com/dist/1P/op2/pkg/v2.10.0-beta.02/op_linux_amd64_v2.10.0-beta.02.zip"
unzip -od "$OP_INSTALL_DIR" op.zip && rm op.zip
elif [[ "$OSTYPE" == "darwin"* ]]; then
curl -sSfLo op.pkg "https://cache.agilebits.com/dist/1P/op2/pkg/v2.10.0-beta.02/op_apple_universal_v2.10.0-beta.02.pkg"
pkgutil --expand op.pkg temp-pkg
tar -xvf temp-pkg/op.pkg/Payload -C "$OP_INSTALL_DIR"
rm -rf temp-pkg && rm op.pkg
fi
}
# Uninstall op-cli
uninstall_op_cli() {
if [[ -d "$OP_INSTALL_DIR" ]]; then
rm -fr "$OP_INSTALL_DIR"
fi
}
populating_secret() {
ref=$(printenv $1)
echo "Populating variable: $1"
secret_value=$("${OP_INSTALL_DIR}/op" read "$ref")
if [ -z "$secret_value" ]; then
echo "Could not find or access secret $ref"
exit 1
fi
# Register a mask for the secret to prevent accidental log exposure.
# To support multiline secrets, escape percent signs and add a mask per line.
escaped_mask_value=$(echo "$secret_value" | sed -e 's/%/%25/g')
IFS=$'\n'
for line in $escaped_mask_value; do
if [ "${#line}" -lt 3 ]; then
# To avoid false positives and unreadable logs, omit mask for lines that are too short.
continue
fi
echo "::add-mask::$line"
done
unset IFS
if [ "$INPUT_EXPORT_ENV" == "true" ]; then
# To support multiline secrets, we'll use the heredoc syntax to populate the environment variables.
# As the heredoc identifier, we'll use a randomly generated 64-character string,
# so that collisions are practically impossible.
random_heredoc_identifier=$(openssl rand -hex 32)
{
# Populate env var, using heredoc syntax with generated identifier
echo "$env_var<<${random_heredoc_identifier}"
echo "$secret_value"
echo "${random_heredoc_identifier}"
} >> $GITHUB_ENV
echo "GITHUB_ENV: $(cat $GITHUB_ENV)"
else
# Prepare the secret_value to be outputed properly (especially multiline secrets)
secret_value=$(echo "$secret_value" | awk -v ORS='%0A' '1')
echo "::set-output name=$env_var::$secret_value"
fi
managed_variables+=("$env_var")
}
# Load environment variables using op cli. Iterate over them to find 1Password references, load the secret values,
# and make them available as environment variables in the next steps.
extract_secrets() {
IFS=$'\n'
for env_var in $("${OP_INSTALL_DIR}/op" env ls); do
populating_secret $env_var
done
}
read -r -a managed_variables <<< "$(printenv $managed_variables_var)"
if [ -z "$OP_CONNECT_TOKEN" ] || [ -z "$OP_CONNECT_HOST" ]; then
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
echo "(\$OP_CONNECT_TOKEN and \$OP_CONNECT_HOST) or \$OP_SERVICE_ACCOUNT_TOKEN must be set"
exit 1
fi
auth_type=$SERVICE_ACCOUNT
fi
printf "Authenticated with %s \n" $auth_type
unset_prev_secrets
install_op_cli
extract_secrets
uninstall_op_cli
unset IFS
# Add extra env var that lists which secrets are managed by 1Password so that in a later step
# these can be unset again.
managed_variables_str=$(IFS=','; echo "${managed_variables[*]}")
echo "$managed_variables_var=$managed_variables_str" >> $GITHUB_ENV

13121
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": "1.2.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 ./",
@@ -40,27 +39,22 @@
},
"homepage": "https://github.com/1Password/load-secrets-action#readme",
"dependencies": {
"@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/core": "^1.10.0",
"@actions/exec": "^1.1.1"
},
"devDependencies": {
"@1password/eslint-config": "^4.3.1",
"@1password/prettier-config": "^1.2.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.30",
"@vercel/ncc": "^0.38.1",
"husky": "^9.0.11",
"jest": "^29.7.0",
"lint-staged": "^15.2.2",
"ts-jest": "^29.1.2",
"typescript": "^5.4.2"
"@1password/front-end-style": "^6.0.1",
"@types/jest": "^29.5.0",
"@types/node": "^18.15.10",
"@vercel/ncc": "^0.36.1",
"husky": "^8.0.3",
"jest": "^29.5.0",
"lint-staged": "^13.2.0",
"ts-jest": "^29.0.5",
"typescript": "^4.9.5"
},
"eslintConfig": {
"extends": "@1password/eslint-config",
"extends": "./node_modules/@1password/front-end-style/eslintrc.yml",
"ignorePatterns": [
"coverage/"
],
@@ -68,5 +62,5 @@
"project": "./tsconfig.json"
}
},
"prettier": "@1password/prettier-config"
"prettier": "./node_modules/@1password/front-end-style/prettierrc.json"
}

View File

@@ -1,7 +0,0 @@
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,31 +1,20 @@
import dotenv from "dotenv";
import path from "path";
import url from "url";
import * as core from "@actions/core";
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
import { envFilePath } from "./constants";
import * as exec from "@actions/exec";
const loadSecretsAction = async () => {
const run = async () => {
try {
const currentFile = url.fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFile);
const parentDir = path.resolve(currentDir, "..");
// Get action inputs
const shouldUnsetPrevious = core.getBooleanInput("unset-previous");
const shouldExportEnv = core.getBooleanInput("export-env");
process.env.INPUT_UNSET_PREVIOUS = core.getInput("unset-previous");
process.env.INPUT_EXPORT_ENV = core.getInput("export-env");
// Unset all secrets managed by 1Password if `unset-previous` is set.
if (shouldUnsetPrevious) {
unsetPrevious();
}
// Validate that a proper authentication configuration is set (Connect or service account)
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 });
}
// Load secrets
await loadSecrets(shouldExportEnv);
// Execute bash script
await exec.exec(`sh -c "` + parentDir + `/entrypoint.sh"`);
} catch (error) {
// It's possible for the Error constructor to be modified to be anything
// in JavaScript, so the following code accounts for this possibility.
@@ -34,10 +23,10 @@ const loadSecretsAction = async () => {
if (error instanceof Error) {
message = error.message;
} else {
message = String(error);
String(error);
}
core.setFailed(message);
}
};
void loadSecretsAction();
void run();

View File

@@ -1,878 +0,0 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { createClient, Secrets } from "@1password/sdk";
import { OnePasswordConnect, FullItem } from "@1password/connect";
import {
loadSecrets,
unsetPrevious,
validateAuth,
findMatchingFieldAndFile,
findSectionIdsByQuery,
parseOpRef,
getEnvVarNamesWithSecretRefs,
} from "./utils";
import {
authErr,
envConnectHost,
envConnectToken,
envManagedVariables,
envServiceAccountToken,
} from "./constants";
jest.mock("@actions/core");
jest.mock("@actions/exec", () => ({
getExecOutput: jest.fn(() => ({
stdout: "MOCK_SECRET",
})),
}));
jest.mock("@1password/sdk", () => ({
createClient: jest.fn(),
// eslint-disable-next-line @typescript-eslint/naming-convention
Secrets: {
validateSecretReference: jest.fn(),
},
}));
jest.mock("@1password/connect");
beforeEach(() => {
jest.clearAllMocks();
});
describe("validateAuth", () => {
const testConnectHost = "https://localhost:8000";
const testConnectToken = "token";
const testServiceAccountToken = "ops_token";
beforeEach(() => {
process.env[envConnectHost] = "";
process.env[envConnectToken] = "";
process.env[envServiceAccountToken] = "";
});
it("should throw an error when no config is provided", () => {
expect(validateAuth).toThrow(authErr);
});
it("should throw an error when partial Connect config is provided", () => {
process.env[envConnectHost] = testConnectHost;
expect(validateAuth).toThrow(authErr);
});
it("should be authenticated as a Connect client", () => {
process.env[envConnectHost] = testConnectHost;
process.env[envConnectToken] = testConnectToken;
expect(validateAuth).not.toThrow(authErr);
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
});
it("should be authenticated as a service account", () => {
process.env[envServiceAccountToken] = testServiceAccountToken;
expect(validateAuth).not.toThrow(authErr);
expect(core.info).toHaveBeenCalledWith(
"Authenticated with Service account.",
);
});
it("should prioritize Connect over service account if both are configured", () => {
process.env[envServiceAccountToken] = testServiceAccountToken;
process.env[envConnectHost] = testConnectHost;
process.env[envConnectToken] = testConnectToken;
expect(validateAuth).not.toThrow(authErr);
expect(core.warning).toHaveBeenCalled();
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
});
});
describe("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 () => {
await loadSecrets(true);
expect(core.exportVariable).toHaveBeenCalledWith(
"MY_SECRET",
"resolved-via-connect",
);
expect(core.exportVariable).toHaveBeenCalledWith(
envManagedVariables,
"MY_SECRET",
);
});
it("return early if no env vars with secrets found", async () => {
delete process.env.MY_SECRET;
await loadSecrets(true);
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",
);
});
it("is not called when shouldExportEnv is false", async () => {
await loadSecrets(false);
expect(core.exportVariable).not.toHaveBeenCalled();
});
});
});
describe("loadSecrets when using Service Account", () => {
const mockResolve = jest.fn();
beforeEach(() => {
process.env[envConnectHost] = "";
process.env[envConnectToken] = "";
process.env[envServiceAccountToken] = "ops_token";
Object.keys(process.env).forEach((key) => {
if (
typeof process.env[key] === "string" &&
process.env[key]?.startsWith("op://")
) {
delete process.env[key];
}
});
process.env.MY_SECRET = "op://vault/item/field";
(createClient as jest.Mock).mockResolvedValue({
secrets: { resolve: mockResolve },
});
mockResolve.mockResolvedValue("resolved-secret-value");
});
it("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://invalid/ref/form";
(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");
}
},
);
mockResolve.mockResolvedValue("value1");
await expect(loadSecrets(false)).rejects.toThrow(
"Invalid secret reference(s): OTHER",
);
expect(mockResolve).not.toHaveBeenCalled();
});
});
});
describe("unsetPrevious", () => {
const testManagedEnv = "TEST_SECRET";
const testSecretValue = "MyS3cr#T";
beforeEach(() => {
process.env[testManagedEnv] = testSecretValue;
process.env[envManagedVariables] = testManagedEnv;
});
it("should unset the environment variable if user wants it", () => {
unsetPrevious();
expect(core.info).toHaveBeenCalledWith("Unsetting previous values ...");
expect(core.info).toHaveBeenCalledWith("Unsetting TEST_SECRET");
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
});
});
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(
/section section-1 could not be found/,
);
});
it("throws when sections is null/undefined", () => {
expect(() =>
findSectionIdsByQuery(undefined as unknown as FullItem["sections"], "x"),
).toThrow(/could not be found/);
});
it("returns section id when section matches by id", () => {
const sections = [{ id: "sec-1", label: "Section 1" }];
expect(
findSectionIdsByQuery(sections as FullItem["sections"], "sec-1"),
).toEqual(["sec-1"]);
});
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(/could not be found/);
});
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,478 +0,0 @@
import * as core from "@actions/core";
import { createClient, Secrets } from "@1password/sdk";
import { OnePasswordConnect, FullItem, OPConnect } from "@1password/connect";
import { version } from "../package.json";
import {
authErr,
envConnectHost,
envConnectToken,
envServiceAccountToken,
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 a file was found, get the content of the file (with retry on 503)
if (fileId) {
const maxAttempts = 3;
const retryDelayMs = 2000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const content = await client.getFileContent(
parsed.vault,
parsed.item,
fileId,
);
return content;
} catch (err) {
const is503 =
err &&
typeof err === "object" &&
(err as Record<string, unknown>).statusCode === 503;
if (is503 && attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
continue;
}
throw err;
}
}
}
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}`,
);
};
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(
`section ${sectionQuery} could not be found in specified item`,
);
}
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(
`section ${sectionQuery} could not be 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];
};
// #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: string[] = [];
for (const envName of envNames) {
const ref = process.env[envName];
if (!ref) {
continue;
}
try {
Secrets.validateSecretReference(ref);
} catch {
invalid.push(envName);
}
}
// Throw an error if any secret references are invalid
if (invalid.length > 0) {
const names = invalid.join(", ");
throw new Error(`Invalid secret reference(s): ${names}`);
}
};
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];
if (isConnect && isServiceAccount) {
core.warning(
"WARNING: Both service account and Connect credentials are provided. Connect credentials will take priority.",
);
}
if (!isConnect && !isServiceAccount) {
throw new Error(authErr);
}
const authType = isConnect ? "Connect" : "Service account";
core.info(`Authenticated with ${authType}.`);
};
export const unsetPrevious = (): void => {
if (process.env[envManagedVariables]) {
core.info("Unsetting previous values ...");
const managedEnvs = process.env[envManagedVariables].split(",");
for (const envName of managedEnvs) {
core.info(`Unsetting ${envName}`);
core.exportVariable(envName, "");
}
}
};
const fetchVaultId = async (
client: OPConnect,
vaultQuery: string,
ref: string,
cache: Map<string, string>,
): Promise<string> => {
// Check if the vault ID is already cached
const cached = cache.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}"`,
);
}
cache.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);
}
// Authenticate with the Connect SDK
let client;
try {
client = 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}`);
}
const vaultIdByQuery = 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,
vaultIdByQuery,
);
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"

View File

@@ -6,6 +6,8 @@
"esModuleInterop": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"importsNotUsedAsValues": "error",
"isolatedModules": true,
"module": "esnext",
"moduleResolution": "node",
"noEmit": true,
@@ -15,9 +17,9 @@
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"outDir": "./dist/",
"rootDir": "./src/",
"strict": true,
"target": "es2022",
"verbatimModuleSyntax": true
"target": "es2022"
}
}