Compare commits
1 Commits
jill/depre
...
v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
971116bbff |
316
.github/workflows/e2e-tests.yml
vendored
316
.github/workflows/e2e-tests.yml
vendored
@@ -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
|
|
||||||
36
.github/workflows/lint-and-test.yml
vendored
36
.github/workflows/lint-and-test.yml
vendored
@@ -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
13
.github/workflows/lint.yml
vendored
Normal 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
|
||||||
25
.github/workflows/ok-to-test.yml
vendored
25
.github/workflows/ok-to-test.yml
vendored
@@ -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
|
|
||||||
13
.github/workflows/pr-check-signed-commits.yml
vendored
13
.github/workflows/pr-check-signed-commits.yml
vendored
@@ -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
|
|
||||||
121
.github/workflows/test-e2e.yml
vendored
121
.github/workflows/test-e2e.yml
vendored
@@ -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
136
.github/workflows/test.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -1,4 +1,2 @@
|
|||||||
coverage/
|
coverage/
|
||||||
node_modules/
|
node_modules/
|
||||||
.idea/
|
|
||||||
1password-credentials.json
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
51
README.md
51
README.md
@@ -23,77 +23,38 @@ Read more on the [1Password Developer Portal](https://developer.1password.com/do
|
|||||||
|
|
||||||
## ✨ Quickstart
|
## ✨ Quickstart
|
||||||
|
|
||||||
### Export secrets as a step's output (recommended)
|
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
on: push
|
on: push
|
||||||
jobs:
|
jobs:
|
||||||
hello-world:
|
hello-world:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Load secret
|
- name: Load secret
|
||||||
id: load_secrets
|
uses: 1password/load-secrets-action@v1
|
||||||
uses: 1password/load-secrets-action@v3
|
|
||||||
env:
|
|
||||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
|
||||||
SECRET: op://app-cicd/hello-world/secret
|
|
||||||
OP_ENV_FILE: "./path/to/.env.tpl" # see tests/.env.tpl for example
|
|
||||||
|
|
||||||
- name: Print masked secret
|
|
||||||
run: 'echo "Secret: ${{ steps.load_secrets.outputs.SECRET }}"'
|
|
||||||
# Prints: Secret: ***
|
|
||||||
```
|
|
||||||
|
|
||||||
### Export secrets as env variables
|
|
||||||
|
|
||||||
```yml
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
hello-world:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Load secret
|
|
||||||
uses: 1password/load-secrets-action@v3
|
|
||||||
with:
|
with:
|
||||||
# Export loaded secrets as environment variables
|
# Export loaded secrets as environment variables
|
||||||
export-env: true
|
export-env: true
|
||||||
env:
|
env:
|
||||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
SECRET: op://app-cicd/hello-world/secret
|
SECRET: op://app-cicd/hello-world/secret
|
||||||
OP_ENV_FILE: "./path/to/.env.tpl" # see tests/.env.tpl for example
|
|
||||||
|
|
||||||
- name: Print masked secret
|
- name: Print masked secret
|
||||||
run: 'echo "Secret: $SECRET"'
|
run: 'echo "Secret: $SECRET"'
|
||||||
# Prints: Secret: ***
|
# Prints: Secret: ***
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔑 SSH Key Format
|
|
||||||
|
|
||||||
When loading SSH keys, you can specify the format using the `ssh-format` query parameter. This is useful when you need the private key in a specific format like OpenSSH.
|
|
||||||
|
|
||||||
```yml
|
|
||||||
- name: Load SSH key
|
|
||||||
uses: 1password/load-secrets-action@v3
|
|
||||||
env:
|
|
||||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
|
||||||
# Load SSH private key in OpenSSH format
|
|
||||||
SSH_PRIVATE_KEY: op://vault/item/private key?ssh-format=openssh
|
|
||||||
```
|
|
||||||
|
|
||||||
For more details on secret reference syntax, see the [1Password documentation](https://developer.1password.com/docs/cli/secret-reference-syntax/#ssh-format-parameter).
|
|
||||||
|
|
||||||
## 💙 Community & Support
|
## 💙 Community & Support
|
||||||
|
|
||||||
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
|
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
|
||||||
- Join the [Developer Slack workspace](https://developer.1password.com/joinslack).
|
- Join the [Developer Slack workspace](https://join.slack.com/t/1password-devs/shared_invite/zt-1halo11ps-6o9pEv96xZ3LtX_VE0fJQA).
|
||||||
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
|
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
|
||||||
|
|
||||||
## 🔐 Security
|
## 🔐 Security
|
||||||
|
|
||||||
1Password requests you practice responsible disclosure if you discover a vulnerability.
|
1Password requests you practice responsible disclosure if you discover a vulnerability.
|
||||||
|
|
||||||
Please file requests by sending an email to bugbounty@agilebits.com.
|
Please file requests via [**BugCrowd**](https://bugcrowd.com/agilebits).
|
||||||
|
|
||||||
|
For information about security practices, please visit the [1Password Bug Bounty Program](https://bugcrowd.com/agilebits).
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ inputs:
|
|||||||
default: "false"
|
default: "false"
|
||||||
export-env:
|
export-env:
|
||||||
description: Export the secrets as environment variables
|
description: Export the secrets as environment variables
|
||||||
default: "false"
|
default: "true"
|
||||||
# Backward-compatible no-op input (intentionally ignored)
|
|
||||||
version:
|
|
||||||
description: "(Deprecated) No longer used. Kept for backwards compatibility."
|
|
||||||
default: "latest"
|
|
||||||
runs:
|
runs:
|
||||||
using: "node20"
|
using: "node16"
|
||||||
main: "dist/index.js"
|
main: "dist/index.js"
|
||||||
|
|||||||
@@ -11,16 +11,7 @@ const jestConfig = {
|
|||||||
testEnvironment: "node",
|
testEnvironment: "node",
|
||||||
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
|
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
|
||||||
transform: {
|
transform: {
|
||||||
".ts": [
|
".ts": ["ts-jest"],
|
||||||
"ts-jest",
|
|
||||||
{
|
|
||||||
// Note: We shouldn't need to include `isolatedModules` here because it's a deprecated config option in TS 5,
|
|
||||||
// but setting it to `true` fixes the `ESM syntax is not allowed in a CommonJS module when
|
|
||||||
// 'verbatimModuleSyntax' is enabled` error that we're seeing when running our Jest tests.
|
|
||||||
isolatedModules: true,
|
|
||||||
useESM: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
verbose: true,
|
verbose: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,5 +9,12 @@ inputs:
|
|||||||
service-account-token:
|
service-account-token:
|
||||||
description: Your 1Password service account token
|
description: Your 1Password service account token
|
||||||
runs:
|
runs:
|
||||||
using: "node20"
|
using: composite
|
||||||
main: "dist/index.js"
|
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
27588
configure/dist/index.js
vendored
File diff suppressed because one or more lines are too long
3
configure/dist/package.json
vendored
3
configure/dist/package.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "commonjs"
|
|
||||||
}
|
|
||||||
21
configure/entrypoint.sh
Executable file
21
configure/entrypoint.sh
Executable 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
|
||||||
@@ -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();
|
|
||||||
12634
dist/index.js
vendored
12634
dist/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/package.json
vendored
2
dist/package.json
vendored
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"type": "commonjs"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
172
entrypoint.sh
Executable file
172
entrypoint.sh
Executable file
@@ -0,0 +1,172 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# shellcheck disable=SC2046,SC2001,SC2086
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Pass User-Agent Inforomation to the 1Password CLI
|
||||||
|
export OP_INTEGRATION_NAME="1Password GitHub Action"
|
||||||
|
export OP_INTEGRATION_ID="GHA"
|
||||||
|
export OP_INTEGRATION_BUILDNUMBER="1010001"
|
||||||
|
|
||||||
|
readonly CONNECT="CONNECT"
|
||||||
|
readonly SERVICE_ACCOUNT="SERVICE_ACCOUNT"
|
||||||
|
|
||||||
|
auth_type=$CONNECT
|
||||||
|
managed_variables_var="OP_MANAGED_VARIABLES"
|
||||||
|
IFS=','
|
||||||
|
|
||||||
|
if [[ "$OP_CONNECT_HOST" != "http://"* ]] && [[ "$OP_CONNECT_HOST" != "https://"* ]]; then
|
||||||
|
export OP_CONNECT_HOST="http://"$OP_CONNECT_HOST
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unset all secrets managed by 1Password if `unset-previous` is set.
|
||||||
|
unset_prev_secrets() {
|
||||||
|
if [ "$INPUT_UNSET_PREVIOUS" == "true" ]; then
|
||||||
|
echo "Unsetting previous values..."
|
||||||
|
|
||||||
|
# Find environment variables that are managed by 1Password.
|
||||||
|
for env_var in "${managed_variables[@]}"; do
|
||||||
|
echo "Unsetting $env_var"
|
||||||
|
unset $env_var
|
||||||
|
|
||||||
|
echo "$env_var=" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# Keep the masks, just in case.
|
||||||
|
done
|
||||||
|
|
||||||
|
managed_variables=()
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install op-cli
|
||||||
|
install_op_cli() {
|
||||||
|
# Create a temporary directory where the CLI is installed
|
||||||
|
OP_INSTALL_DIR="$(mktemp -d)"
|
||||||
|
if [[ ! -d "$OP_INSTALL_DIR" ]]; then
|
||||||
|
echo "Install dir $OP_INSTALL_DIR not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export OP_INSTALL_DIR
|
||||||
|
echo "::debug::OP_INSTALL_DIR: ${OP_INSTALL_DIR}"
|
||||||
|
|
||||||
|
# Get the latest stable version of the CLI
|
||||||
|
OP_CLI_VERSION="v$(curl https://app-updates.agilebits.com/check/1/0/CLI2/en/2.0.0/N -s | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+')"
|
||||||
|
|
||||||
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
|
# Get runner's architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [[ "$(getconf LONG_BIT)" = 32 ]]; then
|
||||||
|
ARCH="386"
|
||||||
|
elif [[ "$ARCH" == "x86_64" ]]; then
|
||||||
|
ARCH="amd64"
|
||||||
|
elif [[ "$ARCH" == "aarch64" ]]; then
|
||||||
|
ARCH="arm64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ARCH" != "386" ]] && [[ "$ARCH" != "amd64" ]] && [[ "$ARCH" != "arm" ]] && [[ "$ARCH" != "arm64" ]]; then
|
||||||
|
echo "Unsupported architecture for the 1Password CLI: $ARCH."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -sSfLo op.zip "https://cache.agilebits.com/dist/1P/op2/pkg/${OP_CLI_VERSION}/op_linux_${ARCH}_${OP_CLI_VERSION}.zip"
|
||||||
|
unzip -od "$OP_INSTALL_DIR" op.zip && rm op.zip
|
||||||
|
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
curl -sSfLo op.pkg "https://cache.agilebits.com/dist/1P/op2/pkg/${OP_CLI_VERSION}/op_apple_universal_${OP_CLI_VERSION}.pkg"
|
||||||
|
pkgutil --expand op.pkg temp-pkg
|
||||||
|
tar -xvf temp-pkg/op.pkg/Payload -C "$OP_INSTALL_DIR"
|
||||||
|
rm -rf temp-pkg && rm op.pkg
|
||||||
|
else
|
||||||
|
echo "Operating system not supported yet for this GitHub Action: $OSTYPE."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uninstall op-cli
|
||||||
|
uninstall_op_cli() {
|
||||||
|
if [[ -d "$OP_INSTALL_DIR" ]]; then
|
||||||
|
rm -fr "$OP_INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
populating_secret() {
|
||||||
|
ref=$(printenv $1)
|
||||||
|
|
||||||
|
echo "Populating variable: $1"
|
||||||
|
secret_value=$("${OP_INSTALL_DIR}/op" read "$ref")
|
||||||
|
|
||||||
|
if [ -z "$secret_value" ]; then
|
||||||
|
echo "Could not find or access secret $ref"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Register a mask for the secret to prevent accidental log exposure.
|
||||||
|
# To support multiline secrets, escape percent signs and add a mask per line.
|
||||||
|
escaped_mask_value=$(echo "$secret_value" | sed -e 's/%/%25/g')
|
||||||
|
IFS=$'\n'
|
||||||
|
for line in $escaped_mask_value; do
|
||||||
|
if [ "${#line}" -lt 3 ]; then
|
||||||
|
# To avoid false positives and unreadable logs, omit mask for lines that are too short.
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "::add-mask::$line"
|
||||||
|
done
|
||||||
|
unset IFS
|
||||||
|
|
||||||
|
# To support multiline secrets, we'll use the heredoc syntax to populate the environment variables.
|
||||||
|
# As the heredoc identifier, we'll use a randomly generated 64-character string,
|
||||||
|
# so that collisions are practically impossible.
|
||||||
|
# Read more: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
|
||||||
|
delimiter="$(openssl rand -hex 32)"
|
||||||
|
|
||||||
|
if [ "$INPUT_EXPORT_ENV" == "true" ]; then
|
||||||
|
{
|
||||||
|
# Populate env var, using heredoc syntax with generated identifier
|
||||||
|
echo "$env_var<<${delimiter}"
|
||||||
|
echo "$secret_value"
|
||||||
|
echo "${delimiter}"
|
||||||
|
} >> $GITHUB_ENV
|
||||||
|
echo "GITHUB_ENV: $(cat $GITHUB_ENV)"
|
||||||
|
|
||||||
|
else
|
||||||
|
{
|
||||||
|
# Populate env var, using heredoc syntax with generated identifier
|
||||||
|
echo "$env_var<<${delimiter}"
|
||||||
|
echo "$secret_value"
|
||||||
|
echo "${delimiter}"
|
||||||
|
} >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
managed_variables+=("$env_var")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load environment variables using op cli. Iterate over them to find 1Password references, load the secret values,
|
||||||
|
# and make them available as environment variables in the next steps.
|
||||||
|
extract_secrets() {
|
||||||
|
IFS=$'\n'
|
||||||
|
for env_var in $("${OP_INSTALL_DIR}/op" env ls); do
|
||||||
|
populating_secret $env_var
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
read -r -a managed_variables <<< "$(printenv $managed_variables_var)"
|
||||||
|
|
||||||
|
if [ -z "$OP_CONNECT_TOKEN" ] || [ -z "$OP_CONNECT_HOST" ]; then
|
||||||
|
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
|
||||||
|
echo "(\$OP_CONNECT_TOKEN and \$OP_CONNECT_HOST) or \$OP_SERVICE_ACCOUNT_TOKEN must be set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
auth_type=$SERVICE_ACCOUNT
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "Authenticated with %s \n" $auth_type
|
||||||
|
|
||||||
|
unset_prev_secrets
|
||||||
|
install_op_cli
|
||||||
|
extract_secrets
|
||||||
|
uninstall_op_cli
|
||||||
|
|
||||||
|
unset IFS
|
||||||
|
# Add extra env var that lists which secrets are managed by 1Password so that in a later step
|
||||||
|
# these can be unset again.
|
||||||
|
managed_variables_str=$(IFS=','; echo "${managed_variables[*]}")
|
||||||
|
echo "$managed_variables_var=$managed_variables_str" >> $GITHUB_ENV
|
||||||
6200
package-lock.json
generated
6200
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -1,15 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "load-secrets-action",
|
"name": "load-secrets-action",
|
||||||
"version": "3.1.0",
|
"version": "1.3.2",
|
||||||
"description": "Load Secrets from 1Password",
|
"description": "Load Secrets from 1Password",
|
||||||
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
"test": "tests"
|
"test": "tests"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "ncc build ./src/index.ts",
|
"build": "ncc build ./src/index.ts",
|
||||||
"build:configure": "ncc build ./configure/index.js -o ./configure/dist",
|
|
||||||
"build:all": "npm run build && npm run build:configure",
|
|
||||||
"format": "prettier --ignore-path ./config/.prettierignore",
|
"format": "prettier --ignore-path ./config/.prettierignore",
|
||||||
"format:check": "npm run format -- --check ./",
|
"format:check": "npm run format -- --check ./",
|
||||||
"format:write": "npm run format -- --write ./",
|
"format:write": "npm run format -- --write ./",
|
||||||
@@ -40,27 +39,22 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@1password/sdk": "^0.4.0",
|
|
||||||
"@1password/connect": "^1.4.2",
|
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^1.1.1"
|
||||||
"@actions/tool-cache": "^2.0.2",
|
|
||||||
"dotenv": "^17.2.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@1password/eslint-config": "^4.3.1",
|
"@1password/front-end-style": "^6.0.1",
|
||||||
"@1password/prettier-config": "^1.2.0",
|
"@types/jest": "^29.5.6",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/node": "^18.18.6",
|
||||||
"@types/node": "^20.11.30",
|
"@vercel/ncc": "^0.36.1",
|
||||||
"@vercel/ncc": "^0.38.1",
|
"husky": "^8.0.3",
|
||||||
"husky": "^9.0.11",
|
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"lint-staged": "^15.2.2",
|
"lint-staged": "^13.3.0",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.1.1",
|
||||||
"typescript": "^5.4.2"
|
"typescript": "^4.9.5"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": "@1password/eslint-config",
|
"extends": "./node_modules/@1password/front-end-style/eslintrc.yml",
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"coverage/"
|
"coverage/"
|
||||||
],
|
],
|
||||||
@@ -68,5 +62,5 @@
|
|||||||
"project": "./tsconfig.json"
|
"project": "./tsconfig.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prettier": "@1password/prettier-config"
|
"prettier": "./node_modules/@1password/front-end-style/prettierrc.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}.`;
|
|
||||||
39
src/index.ts
39
src/index.ts
@@ -1,31 +1,20 @@
|
|||||||
import dotenv from "dotenv";
|
import path from "path";
|
||||||
|
import url from "url";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
|
import * as exec from "@actions/exec";
|
||||||
import { envFilePath } from "./constants";
|
|
||||||
|
|
||||||
const loadSecretsAction = async () => {
|
const run = async () => {
|
||||||
try {
|
try {
|
||||||
|
const currentFile = url.fileURLToPath(import.meta.url);
|
||||||
|
const currentDir = path.dirname(currentFile);
|
||||||
|
const parentDir = path.resolve(currentDir, "..");
|
||||||
|
|
||||||
// Get action inputs
|
// Get action inputs
|
||||||
const shouldUnsetPrevious = core.getBooleanInput("unset-previous");
|
process.env.INPUT_UNSET_PREVIOUS = core.getInput("unset-previous");
|
||||||
const shouldExportEnv = core.getBooleanInput("export-env");
|
process.env.INPUT_EXPORT_ENV = core.getInput("export-env");
|
||||||
|
|
||||||
// Unset all secrets managed by 1Password if `unset-previous` is set.
|
// Execute bash script
|
||||||
if (shouldUnsetPrevious) {
|
await exec.exec(`sh -c "` + parentDir + `/entrypoint.sh"`);
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// It's possible for the Error constructor to be modified to be anything
|
// It's possible for the Error constructor to be modified to be anything
|
||||||
// in JavaScript, so the following code accounts for this possibility.
|
// in JavaScript, so the following code accounts for this possibility.
|
||||||
@@ -34,10 +23,10 @@ const loadSecretsAction = async () => {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
message = error.message;
|
message = error.message;
|
||||||
} else {
|
} else {
|
||||||
message = String(error);
|
String(error);
|
||||||
}
|
}
|
||||||
core.setFailed(message);
|
core.setFailed(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void loadSecretsAction();
|
void run();
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
478
src/utils.ts
478
src/utils.ts
@@ -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
|
|
||||||
@@ -9,11 +9,11 @@ assert_env_equals() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly SECRET="RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
|
assert_env_equals "SECRET" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
|
||||||
readonly FILE_SECRET_CONTENT="This is a test"
|
|
||||||
readonly DOUBLE_SECTION_SECRET_CONTENT="test-password"
|
|
||||||
|
|
||||||
MULTILINE_SECRET="$(cat << EOF
|
assert_env_equals "SECRET_IN_SECTION" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
|
||||||
|
|
||||||
|
assert_env_equals "MULTILINE_SECRET" "$(cat << EOF
|
||||||
-----BEGIN PRIVATE KEY-----
|
-----BEGIN PRIVATE KEY-----
|
||||||
RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLApXaGls
|
RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLApXaGls
|
||||||
ZSB3ZSBkZWVwbHkgYXBwcmVjaWF0ZSB5b3VyIHZp
|
ZSB3ZSBkZWVwbHkgYXBwcmVjaWF0ZSB5b3VyIHZp
|
||||||
@@ -28,18 +28,3 @@ IApTbyBwbGVhc2UgZG9uJ3QgcmVwb3J0IGl0IQo=
|
|||||||
-----END PRIVATE KEY-----
|
-----END PRIVATE KEY-----
|
||||||
EOF
|
EOF
|
||||||
)"
|
)"
|
||||||
readonly MULTILINE_SECRET
|
|
||||||
|
|
||||||
assert_env_equals "SECRET" "${SECRET}"
|
|
||||||
assert_env_equals "FILE_SECRET" "${SECRET}"
|
|
||||||
|
|
||||||
assert_env_equals "SECRET_IN_SECTION" "${SECRET}"
|
|
||||||
assert_env_equals "FILE_SECRET_IN_SECTION" "${SECRET}"
|
|
||||||
|
|
||||||
assert_env_equals "MULTILINE_SECRET" "${MULTILINE_SECRET}"
|
|
||||||
assert_env_equals "FILE_MULTILINE_SECRET" "${MULTILINE_SECRET}"
|
|
||||||
|
|
||||||
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}"
|
|
||||||
|
|||||||
@@ -10,14 +10,5 @@ assert_env_unset() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert_env_unset "SECRET"
|
assert_env_unset "SECRET"
|
||||||
assert_env_unset "FILE_SECRET"
|
|
||||||
|
|
||||||
assert_env_unset "SECRET_IN_SECTION"
|
assert_env_unset "SECRET_IN_SECTION"
|
||||||
assert_env_unset "FILE_SECRET_IN_SECTION"
|
|
||||||
|
|
||||||
assert_env_unset "MULTILINE_SECRET"
|
assert_env_unset "MULTILINE_SECRET"
|
||||||
assert_env_unset "FILE_MULTILINE_SECRET"
|
|
||||||
|
|
||||||
assert_env_unset "SECRET_WITH_FILE"
|
|
||||||
assert_env_unset "SECRET_WITH_FILE_IN_SECTION"
|
|
||||||
assert_env_unset "DOUBLE_SECTION_SECRET"
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"importsNotUsedAsValues": "error",
|
||||||
|
"isolatedModules": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
@@ -15,9 +17,9 @@
|
|||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"resolveJsonModule": true,
|
"outDir": "./dist/",
|
||||||
|
"rootDir": "./src/",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "es2022",
|
"target": "es2022"
|
||||||
"verbatimModuleSyntax": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user