Compare commits

..

1 Commits

Author SHA1 Message Date
Eddy Filip 61def4eb0f Add item create command workaround
This content will easily let the users know the alternate way in which they have to create items in GitHub pipelines.
2023-04-27 12:40:01 +02:00
64 changed files with 13423 additions and 72363 deletions
-250
View File
@@ -1,250 +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 or UUID"
required: true
jobs:
test-service-account:
name: Service Account (${{ matrix.os }}, ${{ matrix.version }}, export-env=${{ matrix.export-env }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
max-parallel: 4
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
version: [latest, 2.30.0]
export-env: [true, false]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Build actions
run: npm run build:all
- name: Generate .env.tpl
shell: bash
run: |
echo "FILE_SECRET=op://${VAULT}/test-secret/password" > tests/.env.tpl
echo "FILE_SECRET_IN_SECTION=op://${VAULT}/test-secret/test-section/password" >> tests/.env.tpl
echo "FILE_MULTILINE_SECRET=op://${VAULT}/multiline-secret/notesPlain" >> tests/.env.tpl
echo "FILE_WEBSITE=op://${VAULT}/test-secret/website" >> tests/.env.tpl
echo "FILE_TEST_SSH_KEY=op://${VAULT}/test-ssh-key/private key" >> tests/.env.tpl
echo "FILE_TEST_SSH_KEY_OPENSSH=op://${VAULT}/test-ssh-key/private key?ssh-format=openssh" >> tests/.env.tpl
env:
VAULT: ${{ secrets.VAULT }}
- name: Configure Service account
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./
with:
version: ${{ matrix.version }}
export-env: ${{ matrix.export-env }}
env:
SECRET: op://${{ secrets.VAULT }}/test-secret/password
SECRET_IN_SECTION: op://${{ secrets.VAULT }}/test-secret/test-section/password
MULTILINE_SECRET: op://${{ secrets.VAULT }}/multiline-secret/notesPlain
WEBSITE: op://${{ secrets.VAULT }}/test-secret/website
TEST_SSH_KEY: op://${{ secrets.VAULT }}/test-ssh-key/private key
TEST_SSH_KEY_OPENSSH: "op://${{ secrets.VAULT }}/test-ssh-key/private key?ssh-format=openssh"
OP_ENV_FILE: ./tests/.env.tpl
- name: Assert test secret values [step output]
if: ${{ !matrix.export-env }}
shell: bash
env:
ASSERT_WEBSITE: "true"
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 }}
WEBSITE: ${{ steps.load_secrets.outputs.WEBSITE }}
FILE_WEBSITE: ${{ steps.load_secrets.outputs.FILE_WEBSITE }}
TEST_SSH_KEY: ${{ steps.load_secrets.outputs.TEST_SSH_KEY }}
FILE_TEST_SSH_KEY: ${{ steps.load_secrets.outputs.FILE_TEST_SSH_KEY }}
TEST_SSH_KEY_OPENSSH: ${{ steps.load_secrets.outputs.TEST_SSH_KEY_OPENSSH }}
FILE_TEST_SSH_KEY_OPENSSH: ${{ steps.load_secrets.outputs.FILE_TEST_SSH_KEY_OPENSSH }}
run: ./tests/assert-env-set.sh
- name: Assert SSH key env vars [step output]
if: ${{ !matrix.export-env }}
shell: bash
env:
TEST_SSH_KEY: ${{ steps.load_secrets.outputs.TEST_SSH_KEY }}
FILE_TEST_SSH_KEY: ${{ steps.load_secrets.outputs.FILE_TEST_SSH_KEY }}
TEST_SSH_KEY_OPENSSH: ${{ steps.load_secrets.outputs.TEST_SSH_KEY_OPENSSH }}
FILE_TEST_SSH_KEY_OPENSSH: ${{ steps.load_secrets.outputs.FILE_TEST_SSH_KEY_OPENSSH }}
run: ./tests/assert-ssh-keys-set.sh
- name: Assert test secret values [exported env]
if: ${{ matrix.export-env }}
shell: bash
env:
ASSERT_WEBSITE: "true"
run: ./tests/assert-env-set.sh
- name: Assert SSH key env vars [exported env]
if: ${{ matrix.export-env }}
shell: bash
run: ./tests/assert-ssh-keys-set.sh
- name: Remove secrets [exported env]
if: ${{ matrix.export-env }}
uses: ./
with:
unset-previous: true
- name: Assert removed secrets [exported env]
if: ${{ matrix.export-env }}
shell: bash
run: ./tests/assert-env-unset.sh
test-connect:
name: Connect (ubuntu-latest, ${{ matrix.version }}, export-env=${{ matrix.export-env }})
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
version: [latest, 2.30.0]
export-env: [true, false]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
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://${VAULT}/test-secret/password" > tests/.env.tpl
echo "FILE_SECRET_IN_SECTION=op://${VAULT}/test-secret/test-section/password" >> tests/.env.tpl
echo "FILE_MULTILINE_SECRET=op://${VAULT}/multiline-secret/notesPlain" >> tests/.env.tpl
echo "FILE_TEST_SSH_KEY=op://${VAULT}/test-ssh-key/private key" >> tests/.env.tpl
echo "FILE_TEST_SSH_KEY_OPENSSH=op://${VAULT}/test-ssh-key/private key?ssh-format=openssh" >> tests/.env.tpl
env:
VAULT: ${{ secrets.VAULT }}
- 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
timeout 60 bash -c 'until curl -sf http://localhost:8080/health >/dev/null 2>&1; do sleep 2; done'
- name: Configure 1Password Connect
uses: ./configure
with:
connect-host: http://localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./
with:
version: ${{ matrix.version }}
export-env: ${{ matrix.export-env }}
env:
SECRET: op://${{ secrets.VAULT }}/test-secret/password
SECRET_IN_SECTION: op://${{ secrets.VAULT }}/test-secret/test-section/password
MULTILINE_SECRET: op://${{ secrets.VAULT }}/multiline-secret/notesPlain
TEST_SSH_KEY: op://${{ secrets.VAULT }}/test-ssh-key/private key
TEST_SSH_KEY_OPENSSH: "op://${{ secrets.VAULT }}/test-ssh-key/private key?ssh-format=openssh"
OP_ENV_FILE: ./tests/.env.tpl
- name: Assert test secret values [step output]
if: ${{ !matrix.export-env }}
env:
ASSERT_WEBSITE: "false"
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 }}
TEST_SSH_KEY: ${{ steps.load_secrets.outputs.TEST_SSH_KEY }}
FILE_TEST_SSH_KEY: ${{ steps.load_secrets.outputs.FILE_TEST_SSH_KEY }}
TEST_SSH_KEY_OPENSSH: ${{ steps.load_secrets.outputs.TEST_SSH_KEY_OPENSSH }}
FILE_TEST_SSH_KEY_OPENSSH: ${{ steps.load_secrets.outputs.FILE_TEST_SSH_KEY_OPENSSH }}
run: ./tests/assert-env-set.sh
- name: Assert SSH key env vars [step output]
if: ${{ !matrix.export-env }}
env:
TEST_SSH_KEY: ${{ steps.load_secrets.outputs.TEST_SSH_KEY }}
FILE_TEST_SSH_KEY: ${{ steps.load_secrets.outputs.FILE_TEST_SSH_KEY }}
TEST_SSH_KEY_OPENSSH: ${{ steps.load_secrets.outputs.TEST_SSH_KEY_OPENSSH }}
FILE_TEST_SSH_KEY_OPENSSH: ${{ steps.load_secrets.outputs.FILE_TEST_SSH_KEY_OPENSSH }}
run: ./tests/assert-ssh-keys-set.sh
- name: Assert test secret values [exported env]
if: ${{ matrix.export-env }}
env:
ASSERT_WEBSITE: "false"
run: ./tests/assert-env-set.sh
- name: Assert SSH key env vars [exported env]
if: ${{ matrix.export-env }}
run: ./tests/assert-ssh-keys-set.sh
- name: Remove secrets [exported env]
if: ${{ matrix.export-env }}
uses: ./
with:
unset-previous: true
- name: Assert removed secrets [exported env]
if: ${{ matrix.export-env }}
run: ./tests/assert-env-unset.sh
-36
View File
@@ -1,36 +0,0 @@
name: Lint and Test
on:
push:
branches: [main]
pull_request:
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
with:
ignore_paths: >-
.husky
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
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
View File
@@ -0,0 +1,13 @@
on: pull_request
name: Lint
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@2.0.0
with:
ignore_paths: >-
.husky
-25
View File
@@ -1,25 +0,0 @@
# Write comments "/ok-to-test sha=<hash>" on a pull request. This will emit a repository_dispatch event.
name: Ok To Test
on:
issue_comment:
types: [created]
jobs:
ok-to-test:
runs-on: ubuntu-latest
permissions:
pull-requests: write # For adding reactions to the pull request comments
contents: write # For executing the repository_dispatch event
# Only run for PRs, not issue comments
if: ${{ github.event.issue.pull_request }}
steps:
- name: Slash Command Dispatch
uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # 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
@@ -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@ed2885f3ed2577a4f5d3c3fe895432a557d23d52 # v1
-128
View File
@@ -1,128 +0,0 @@
name: E2E Tests
on:
push:
branches: [main, jill/test-verification]
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" ] && [ "${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
elif [ "${{ github.event_name }}" == "push" ] && [ "${REF_NAME}" == "jill/test-verification" ]; then
echo "condition=push-to-test-branch" >> $GITHUB_OUTPUT
echo "Setting condition=push-to-test-branch (push to jill/test-verification)"
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
env:
REF_NAME: ${{ github.ref_name }}
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'
||
needs.check-external-pr.outputs.condition == 'push-to-test-branch'
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 }}
# 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@e8674b075228eee787fea43ef493e45ece1004c9 # 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
View File
@@ -0,0 +1,136 @@
on: push
name: Run acceptance tests
jobs:
test-with-output-secrets:
strategy:
matrix:
os: [ ubuntu-latest, macos-latest ]
auth: [ connect, service-account ]
exclude:
- os: macos-latest
auth: connect
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Launch 1Password Connect instance
if: ${{ matrix.auth == 'connect' }}
env:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
run: |
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
- name: Configure Service account
if: ${{ matrix.auth == 'service-account' }}
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Configure 1Password Connect
if: ${{ matrix.auth == 'connect' }}
uses: ./configure # 1password/load-secrets-action/configure@<version>
with:
connect-host: localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./ # 1password/load-secrets-action@<version>
with:
export-env: false
env:
SECRET: op://acceptance-tests/test-secret/password
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
- name: Assert test secret values
env:
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
run: ./tests/assert-env-set.sh
test-with-export-env:
strategy:
matrix:
os: [ ubuntu-latest, macos-latest ]
auth: [ connect, service-account ]
exclude:
- os: macos-latest
auth: connect
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Launch 1Password Connect instance
if: ${{ matrix.auth == 'connect' }}
env:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
run: |
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
- name: Configure Service account
if: ${{ matrix.auth == 'service-account' }}
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Configure 1Password Connect
if: ${{ matrix.auth == 'connect' }}
uses: ./configure # 1password/load-secrets-action/configure@<version>
with:
connect-host: http://localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./ # 1password/load-secrets-action@<version>
env:
SECRET: op://acceptance-tests/test-secret/password
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
- name: Assert test secret values
run: ./tests/assert-env-set.sh
- name: Remove secrets
uses: ./ # 1password/load-secrets-action@<version>
with:
unset-previous: true
- name: Assert removed secrets
run: ./tests/assert-env-unset.sh
test-references-with-ids:
strategy:
matrix:
os: [ ubuntu-latest, macos-latest ]
auth: [ connect, service-account ]
exclude:
- os: macos-latest
auth: connect
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Launch 1Password Connect instance
if: ${{ matrix.auth == 'connect' }}
env:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
run: |
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
- name: Configure Service account
if: ${{ matrix.auth == 'service-account' }}
uses: ./configure
with:
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Configure 1Password Connect
if: ${{ matrix.auth == 'connect' }}
uses: ./configure # 1password/load-secrets-action/configure@<version>
with:
connect-host: http://localhost:8080
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secrets
id: load_secrets
uses: ./ # 1password/load-secrets-action@<version>
with:
export-env: false
env:
SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
SECRET_IN_SECTION: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/Section_tco6nsqycj6jcbyx63h5isxcny/doxu3mhkozcznnk5vjrkpdqayy
MULTILINE_SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
- name: Assert test secret values
env:
SECRET: ${{ steps.load_secrets.outputs.SECRET }}
SECRET_IN_SECTION: ${{ steps.load_secrets.outputs.SECRET_IN_SECTION }}
MULTILINE_SECRET: ${{ steps.load_secrets.outputs.MULTILINE_SECRET }}
run: ./tests/assert-env-set.sh
-2
View File
@@ -1,4 +1,2 @@
coverage/ coverage/
node_modules/ node_modules/
.idea/
1password-credentials.json
-54
View File
@@ -1,54 +0,0 @@
# Contributing
Thank you for your interest in contributing to the 1Password load-secrets-action project 👋! Before you start, please take a moment to read through this guide to understand our contribution process.
## Testing
Unit tests can be run with `npm run test`.
After following the steps below for signing commits, you can test against your PR with these steps:
1. Create or use an existing repo to run the `load-secrets` GitHub Action.
2. In a workflow yaml file that uses the GitHub Action, modify the `uses: 1Password/load-secrets-action` line to be
```yaml
uses: 1Password/load-secrets-action@<branch-name>
```
OR
```yaml
uses: 1Password/load-secrets-action@<commit-hash>
```
3. Trigger the action, which now includes your changes.
## Documentation Updates
If applicable, update the [README.md](./README.md) to reflect any changes introduced by the new code.
## Sign your commits
To get your PR merged, we require you to sign your commits.
### Sign commits with 1Password
You can also sign commits using 1Password, which lets you sign commits with biometrics without the signing key leaving the local 1Password process.
Learn how to use [1Password to sign your commits](https://developer.1password.com/docs/ssh/git-commit-signing/).
### Sign commits with ssh-agent
Follow the steps below to set up commit signing with `ssh-agent`:
1. [Generate an SSH key and add it to ssh-agent](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent)
2. [Add the SSH key to your GitHub account](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account)
3. [Configure git to use your SSH key for commits signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key#telling-git-about-your-ssh-key)
### Sign commits with gpg
Follow the steps below to set up commit signing with `gpg`:
1. [Generate a GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key)
2. [Add the GPG key to your GitHub account](https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account)
3. [Configure git to use your GPG key for commits signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key#telling-git-about-your-gpg-key)
+412 -43
View File
@@ -1,29 +1,38 @@
<!-- Image sourced from https://blog.1password.com/1password-service-accounts/ --> # Load Secrets from 1Password - GitHub Action
<img alt="" role="img" src="https://blog.1password.com/posts/2023/1password-service-accounts/header.png"/>
<div align="center"> This action loads secrets from 1Password into GitHub Actions using [1Password Connect](https://developer.1password.com/docs/connect) or a [Service Account <sup>[BETA]</sup>](https://developer.1password.com/docs/service-accounts).
<h1>Load Secrets from 1Password - GitHub Action</h1>
<p>Provide the secrets your GitHub runner needs from 1Password.</p>
<a href="https://developer.1password.com/docs/ci-cd/github-actions">
<img alt="Get started" src="https://user-images.githubusercontent.com/45081667/226940040-16d3684b-60f4-4d95-adb2-5757a8f1bc15.png" height="37"/>
</a>
</div>
---
`load-secrets-action` loads secrets from 1Password into GitHub Actions using [Service Accounts](https://developer.1password.com/docs/service-accounts) or [1Password Connect](https://developer.1password.com/docs/connect).
Specify in your workflow YAML file which secrets from 1Password should be loaded into your job, and the action will make them available as environment variables for the next steps. Specify in your workflow YAML file which secrets from 1Password should be loaded into your job, and the action will make them available as environment variables for the next steps.
Read more on the [1Password Developer Portal](https://developer.1password.com/docs/ci-cd/github-actions). Read more on the [1Password Developer Portal](https://developer.1password.com/docs/ci-cd/github-actions).
## 🪄 See it in action! ## Requirements
[![Using 1Password Service Accounts with GitHub Actions - showcase](https://img.youtube.com/vi/kVBl5iQYgSA/maxresdefault.jpg)](https://www.youtube.com/watch?v=kVBl5iQYgSA "Using 1Password Service Accounts with GitHub Actions") Before you get started, you'll need to:
## ✨ Quickstart - [Deploy 1Password Connect](/docs/connect/get-started#step-2-deploy-1password-connect-server) in your infrastructure.
- Set the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` environment variables to your Connect instance's credentials, so it'll be used to load secrets.
### Export secrets as a step's output (recommended) _Supported runners_: You can run the action on Mac and Linux runners. Windows is currently not supported.
## Usage
You can configure the action to use your 1Password Connect instance.
If you provide `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` variables, the Connect instance will be used to load secrets. Make sure [1Password Connect](https://support.1password.com/secrets-automation/#step-2-deploy-a-1password-connect-server) is deployed in your infrastructure.
If you provide `OP_SERVICE_ACCOUNT_TOKEN` variable, the service account will be used to load secrets.
**_Note_**: If all environment variables have been set, the Connect credentials will take precedence over the provided service account token. You must unset the Connect environment variables to ensure the action uses the service account token.
There are two ways that secrets can be loaded:
- [use the secrets from the action's ouput](#use-secrets-from-the-actions-output)
- [export secrets as environment variables](#export-secrets-as-environment-variables)
### Use secrets from the action's output
This method allows for you to use the loaded secrets as an output from the step: `steps.step-id.outputs.secret-name`. You will need to set an id for the step that uses this action to be able to access its outputs. For more details, , see [`outputs.<output_id>`](https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputsoutput_id).
```yml ```yml
on: push on: push
@@ -31,22 +40,25 @@ 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 id: op-load-secret
uses: 1password/load-secrets-action@v4 uses: 1password/load-secrets-action@v1
with:
export-env: false
env: env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} OP_CONNECT_HOST: <Your Connect instance URL>
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
SECRET: op://app-cicd/hello-world/secret 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: ${{ steps.load_secrets.outputs.SECRET }}"' run: echo "Secret: ${{ steps.op-load-secret.outputs.SECRET }}"
# Prints: Secret: *** # Prints: Secret: ***
``` ```
### Export secrets as env variables <details>
<summary><b>Usage example with Service Accounts <sup>BETA</sup></b></summary>
```yml ```yml
on: push on: push
@@ -54,46 +66,403 @@ 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
uses: 1password/load-secrets-action@v4 id: op-load-secret
uses: 1password/load-secrets-action@v1
with:
export-env: false
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
SECRET: op://app-cicd/hello-world/secret
- name: Print masked secret
run: echo "Secret: ${{ steps.op-load-secret.outputs.SECRET }}"
# Prints: Secret: ***
```
</details>
<details>
<summary><b>Longer usage example</b></summary>
```yml
on: push
name: Deploy app
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
# Persist the 1Password Connect URL for next steps. You can also persist
# the Connect token using input `connect-token`, but keep in mind that
# this will grant all steps of the job access to the token.
connect-host: https://1password.acme.com
- name: Load Docker credentials
id: load-docker-credentials
uses: 1password/load-secrets-action@v1
with:
export-env: false
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
DOCKERHUB_USERNAME: op://app-cicd/docker/username
DOCKERHUB_TOKEN: op://app-cicd/docker/token
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_USERNAME }}
password: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: acme/app:latest
```
</details>
<details>
<summary><b>Longer usage example with Service Accounts <sup>BETA</sup></b></summary>
```yml
on: push
name: Deploy app
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
# Persist the 1Password Service Account token. This will grant
# all steps of the job access to the token.
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Load Docker credentials
id: load-docker-credentials
uses: 1password/load-secrets-action@v1
with:
export-env: false
env:
DOCKERHUB_USERNAME: op://app-cicd/docker/username
DOCKERHUB_TOKEN: op://app-cicd/docker/token
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_USERNAME }}
password: ${{ steps.load-docker-credentials.outputs.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: acme/app:latest
```
</details>
### Export secrets as environment variables
This method, allows the action to access the loaded secrets as environment variables. These environment variables are accessible at a job level.
```yml
on: push
jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Load secret
uses: 1password/load-secrets-action@v1
with: 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 <details>
<summary><b>Usage example with Service Accounts <sup>BETA</sup></b></summary>
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 ```yml
- name: Load SSH key on: push
uses: 1password/load-secrets-action@v4 jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Load secret
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
env: env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} OP_CONNECT_HOST: <Your Connect instance URL>
# Load SSH private key in OpenSSH format OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
SSH_PRIVATE_KEY: op://vault/item/private key?ssh-format=openssh SECRET: op://app-cicd/hello-world/secret
- name: Print masked secret
run: echo "Secret: $SECRET"
# Prints: Secret: ***
``` ```
For more details on secret reference syntax, see the [1Password CLI documentation](https://developer.1password.com/docs/cli/secret-reference-syntax/#ssh-format-parameter). </details>
## 💙 Community & Support <details>
<summary><b>Longer usage example</b></summary>
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests. ```yml
- Join the [Developer Slack workspace](https://developer.1password.com/joinslack). on: push
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/). name: Deploy app
## 🔐 Security jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
# Persist the 1Password Connect URL for next steps. You can also persist
# the Connect token using input `connect-token`, but keep in mind that
# this will grant all steps of the job access to the token.
connect-host: https://1password.acme.com
- name: Load Docker credentials
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
DOCKERHUB_USERNAME: op://app-cicd/docker/username
DOCKERHUB_TOKEN: op://app-cicd/docker/token
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Print environment variables with masked secrets
run: printenv
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: acme/app:latest
- name: Load AWS credentials
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
# Remove local copies of the Docker credentials, which aren't needed anymore
unset-previous: true
env:
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
AWS_ACCESS_KEY_ID: op://app-cicd/aws/access-key-id
AWS_SECRET_ACCESS_KEY: op://app-cicd/aws/secret-access-key
- name: Deploy app
# This script expects AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set.
# This happened using secret references in the preceding lines.
run: ./deploy.sh
```
</details>
<details>
<summary><b>Longer usage example with Service Accounts <sup>BETA</sup></b></summary>
```yml
on: push
name: Deploy app
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
# Persist the 1Password Service Account token. This will grant
# all steps of the job access to the token.
service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
- name: Load Docker credentials
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
env:
DOCKERHUB_USERNAME: op://app-cicd/docker/username
DOCKERHUB_TOKEN: op://app-cicd/docker/token
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Print environment variables with masked secrets
run: printenv
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: acme/app:latest
- name: Load AWS credentials
uses: 1password/load-secrets-action@v1
with:
# Export loaded secrets as environment variables
export-env: true
# Remove local copies of the Docker credentials, which aren't needed anymore
unset-previous: true
env:
AWS_ACCESS_KEY_ID: op://app-cicd/aws/access-key-id
AWS_SECRET_ACCESS_KEY: op://app-cicd/aws/secret-access-key
- name: Deploy app
# This script expects AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set.
# This happened using secret references in the preceding lines.
run: ./deploy.sh
```
</details>
## Action Inputs
| Name | Default | Description |
| ---------------- | ------- | ---------------------------------------------------------------------------------- |
| `export-env` | `true` | Export the loaded secrets as environment variables |
| `unset-previous` | `false` | Whether to unset environment variables populated by 1Password in earlier job steps |
## Secrets Reference Syntax
To specify which secret should be loaded into which environment variable, the action will look for `op://` reference URIs in environment variables, and replace those with the actual secret values.
These reference URIs have the following syntax:
> `op://<vault>/<item>[/<section>]/<field>`
So for example, the reference URI `op://app-cicd/aws/secret-access-key` would be interpreted as:
- **Vault:** `app-cicd`
- **Item:** `aws`
- **Section:** default section
- **Field:** `secret-access-key`
## Masking
Similar to regular GitHub repository secrets, fields from 1Password will automatically be masked from the GitHub Actions logs too.
So if one of these values accidentally gets printed, it'll get replaced with `***`.
## 1Password Configuration
To use the action with Connect, you need to have a [1Password Connect](https://support.1password.com/secrets-automation/#step-1-set-up-a-secrets-automation-workflow) instance deployed somewhere.
To configure the action with your Connect host and token, set the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` environment variables.
To configure the action with your service account token <sup>BETA</sup>, set the `OP_SERVICE_ACCOUNT_TOKEN` environment variable.
If you're using the `load-secrets` action more than once in a single job, you can use the `configure` action to avoid duplicate configuration:
```yml
on: push
jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure 1Password Connect
uses: 1password/load-secrets-action/configure@v1
with:
connect-host: <Your Connect instance URL>
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
- name: Load secret
uses: 1password/load-secrets-action@v1
env:
SECRET: op://app-cicd/hello-world/secret
```
### `configure` Action Inputs
| Name | Environment variable | Description |
| ----------------------- | -------------------------- | -------------------------------------------------------- |
| `connect-host` | `OP_CONNECT_HOST` | Your 1Password Connect instance URL |
| `connect-token` | `OP_CONNECT_TOKEN` | Token to authenticate to your 1Password Connect instance |
| `service-account-token` | `OP_SERVICE_ACCOUNT_TOKEN` | Your 1Password service account token |
## Supported Runners
You can run the action on Linux and macOS runners. Windows is currently not supported.
## Warnings
If you're using the CLI in your GitHub pipelines and you want to create items with it, the following command will fail:
```
op item create --category=login --title='My Example Item' --vault='Test' \
--url https://www.acme.com/login \
--generate-password=20,letters,digits \
username=jane@acme.com \
'Test Field 1=my test secret' \
'Test Section 1.Test Field2[text]=Jane Doe' \
'Test Section 1.Test Field3[date]=1995-02-23' \
'Test Section 2.Test Field4[text]='$myNotes
```
This is caused by the fact that the environment in these pipelines is in piped mode, which triggers the CLI's pipe detection to expect a piped input.
To be able to create items in such environments, do the following steps:
1. Get the template of the item category you want:
```sh
op item template get --out-file=new-item.json <category>
```
2. Edit [the template](https://developer.1password.com/docs/cli/item-template-json) to add your information.
3. Pipe the item content to the command:
```sh
cat new-item.json | op item create --vault='Test'
```
## Security
1Password requests you practice responsible disclosure if you discover a vulnerability. 1Password requests you practice responsible disclosure if you discover a vulnerability.
Please file requests by sending an email to bugbounty@agilebits.com. Please file requests through [BugCrowd](https://bugcrowd.com/agilebits).
For information about our security practices, visit the [1Password Security homepage](https://1password.com/security).
## Getting help
If you find yourself stuck, visit our [**Support Page**](https://support.1password.com/) for help.
+2 -5
View File
@@ -10,10 +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"
version:
description: Specify which 1Password CLI version to install. Defaults to "latest".
default: "latest"
runs: runs:
using: "node24" using: "node16"
main: "dist/index.js" main: "dist/index.js"
+2 -16
View File
@@ -10,24 +10,10 @@ const jestConfig = {
rootDir: "../src/", rootDir: "../src/",
testEnvironment: "node", testEnvironment: "node",
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts", testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
moduleNameMapper: {
"^@actions/core$": "<rootDir>/__mocks__/actions-core.ts",
"^@actions/tool-cache$": "<rootDir>/__mocks__/actions-tool-cache.ts",
"^@actions/exec$": "<rootDir>/__mocks__/actions-exec.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,
}; };
module.exports = jestConfig; export default jestConfig;
+9 -2
View File
@@ -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: "node24" 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
-31022
View File
File diff suppressed because one or more lines are too long
-3
View File
@@ -1,3 +0,0 @@
{
"type": "commonjs"
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# shellcheck disable=SC2086
set -e
# Capture Connect configuration in $GITHUB_ENV, giving (optional) inputs
# precendence over OP_CONNECT_* environment variables.
OP_CONNECT_HOST="${INPUT_CONNECT_HOST:-$OP_CONNECT_HOST}"
if [ -n "$OP_CONNECT_HOST" ]; then
echo "OP_CONNECT_HOST=$OP_CONNECT_HOST" >> $GITHUB_ENV
fi
OP_CONNECT_TOKEN="${INPUT_CONNECT_TOKEN:-$OP_CONNECT_TOKEN}"
if [ -n "$OP_CONNECT_TOKEN" ]; then
echo "OP_CONNECT_TOKEN=$OP_CONNECT_TOKEN" >> $GITHUB_ENV
fi
OP_SERVICE_ACCOUNT_TOKEN="${INPUT_SERVICE_ACCOUNT_TOKEN:-$OP_SERVICE_ACCOUNT_TOKEN}"
if [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
echo "OP_SERVICE_ACCOUNT_TOKEN=$OP_SERVICE_ACCOUNT_TOKEN" >> $GITHUB_ENV
fi
-27
View File
@@ -1,27 +0,0 @@
import * as core from "@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();
+3363 -35031
View File
File diff suppressed because one or more lines are too long
-49
View File
@@ -1,49 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
P4I7jHwe0BN3iNAG1nKbGg==
=+LeX
-----END PGP PUBLIC KEY BLOCK-----
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"type": "commonjs" "type": "module"
} }
-32
View File
@@ -1,32 +0,0 @@
# Fork PR Testing Guide
This document explains how testing works for external pull requests from forks.
## Overview
The testing system consists of two main workflows:
1. **E2E Tests** (`test-e2e.yml`) - Runs automatically for internal PRs, need manual trigger on external PRs.
2. **Ok To Test** (`ok-to-test.yml`) - Dispatches `repository_dispatch` event when maintainer puts the `/ok-to-test sha=<commit hash>` comment in the forked PR thread.
## How It Works
### 1. PR is created by maintainer:
For the PR created by maintainer `E2E Test` workflow starts automatically. The PR check will reflect the status of the job.
### 2. PR is created by external contributor:
For the PR created by external contributor `E2E Test` workflow **won't** start automatically.
Maintainer should make a sanity check of the changes and run it manually by:
1. Putting a comment `/ok-to-test sha=<latest commit hash>` in the PR thread.
2. `E2E Test` workflow starts.
3. After `E2E Test` workflow finishes, a comment with a link to the workflow, along with its status will be posted in the PR.
4. Maintainer can merge PR or request the changes based on the `E2E Test` results.
## Notes
- Only users with **write** permissions can trigger the `/ok-to-test` command.
- External PRs are automatically detected and prevented from running e2e tests automatically.
- Running e2e test on the external PR is optional. Maintainer can merge PR without running it. Maintainer decides whether it's needed to run an E2E test.
-46
View File
@@ -1,46 +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 or UUID |
## Building Before Testing
If you've modified TypeScript code, rebuild before running E2E tests:
```bash
npm run build
```
## Testing
### Run E2E tests using Service Account
```bash
act push -W .github/workflows/e2e-tests.yml \
-s OP_SERVICE_ACCOUNT_TOKEN="$OP_SERVICE_ACCOUNT_TOKEN" \
-s VAULT="$VAULT" \
-j test-service-account \
--matrix os:ubuntu-latest
```
## Run unit tests
```bash
npm test
```
Executable
+146
View File
@@ -0,0 +1,146 @@
#!/bin/bash
# shellcheck disable=SC2046,SC2001,SC2086
set -e
# Pass User-Agent Inforomation to the 1Password CLI
export OP_INTEGRATION_NAME="1Password GitHub Action"
export OP_INTEGRATION_ID="GHA"
export OP_INTEGRATION_BUILDNUMBER="1010001"
readonly CONNECT="CONNECT"
readonly SERVICE_ACCOUNT="SERVICE_ACCOUNT"
auth_type=$CONNECT
managed_variables_var="OP_MANAGED_VARIABLES"
IFS=','
if [[ "$OP_CONNECT_HOST" != "http://"* ]] && [[ "$OP_CONNECT_HOST" != "https://"* ]]; then
export OP_CONNECT_HOST="http://"$OP_CONNECT_HOST
fi
# Unset all secrets managed by 1Password if `unset-previous` is set.
unset_prev_secrets() {
if [ "$INPUT_UNSET_PREVIOUS" == "true" ]; then
echo "Unsetting previous values..."
# Find environment variables that are managed by 1Password.
for env_var in "${managed_variables[@]}"; do
echo "Unsetting $env_var"
unset $env_var
echo "$env_var=" >> $GITHUB_ENV
# Keep the masks, just in case.
done
managed_variables=()
fi
}
# Install op-cli
install_op_cli() {
OP_INSTALL_DIR="$(mktemp -d)"
if [[ ! -d "$OP_INSTALL_DIR" ]]; then
echo "Install dir $OP_INSTALL_DIR not found"
exit 1
fi
export OP_INSTALL_DIR
echo "::debug::OP_INSTALL_DIR: ${OP_INSTALL_DIR}"
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
curl -sSfLo op.zip "https://cache.agilebits.com/dist/1P/op2/pkg/v2.10.0-beta.02/op_linux_amd64_v2.10.0-beta.02.zip"
unzip -od "$OP_INSTALL_DIR" op.zip && rm op.zip
elif [[ "$OSTYPE" == "darwin"* ]]; then
curl -sSfLo op.pkg "https://cache.agilebits.com/dist/1P/op2/pkg/v2.10.0-beta.02/op_apple_universal_v2.10.0-beta.02.pkg"
pkgutil --expand op.pkg temp-pkg
tar -xvf temp-pkg/op.pkg/Payload -C "$OP_INSTALL_DIR"
rm -rf temp-pkg && rm op.pkg
fi
}
# Uninstall op-cli
uninstall_op_cli() {
if [[ -d "$OP_INSTALL_DIR" ]]; then
rm -fr "$OP_INSTALL_DIR"
fi
}
populating_secret() {
ref=$(printenv $1)
echo "Populating variable: $1"
secret_value=$("${OP_INSTALL_DIR}/op" read "$ref")
if [ -z "$secret_value" ]; then
echo "Could not find or access secret $ref"
exit 1
fi
# Register a mask for the secret to prevent accidental log exposure.
# To support multiline secrets, escape percent signs and add a mask per line.
escaped_mask_value=$(echo "$secret_value" | sed -e 's/%/%25/g')
IFS=$'\n'
for line in $escaped_mask_value; do
if [ "${#line}" -lt 3 ]; then
# To avoid false positives and unreadable logs, omit mask for lines that are too short.
continue
fi
echo "::add-mask::$line"
done
unset IFS
if [ "$INPUT_EXPORT_ENV" == "true" ]; then
# To support multiline secrets, we'll use the heredoc syntax to populate the environment variables.
# As the heredoc identifier, we'll use a randomly generated 64-character string,
# so that collisions are practically impossible.
random_heredoc_identifier=$(openssl rand -hex 32)
{
# Populate env var, using heredoc syntax with generated identifier
echo "$env_var<<${random_heredoc_identifier}"
echo "$secret_value"
echo "${random_heredoc_identifier}"
} >> $GITHUB_ENV
echo "GITHUB_ENV: $(cat $GITHUB_ENV)"
else
# Prepare the secret_value to be outputed properly (especially multiline secrets)
secret_value=$(echo "$secret_value" | awk -v ORS='%0A' '1')
echo "::set-output name=$env_var::$secret_value"
fi
managed_variables+=("$env_var")
}
# Load environment variables using op cli. Iterate over them to find 1Password references, load the secret values,
# and make them available as environment variables in the next steps.
extract_secrets() {
IFS=$'\n'
for env_var in $("${OP_INSTALL_DIR}/op" env ls); do
populating_secret $env_var
done
}
read -r -a managed_variables <<< "$(printenv $managed_variables_var)"
if [ -z "$OP_CONNECT_TOKEN" ] || [ -z "$OP_CONNECT_HOST" ]; then
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
echo "(\$OP_CONNECT_TOKEN and \$OP_CONNECT_HOST) or \$OP_SERVICE_ACCOUNT_TOKEN must be set"
exit 1
fi
auth_type=$SERVICE_ACCOUNT
fi
printf "Authenticated with %s \n" $auth_type
unset_prev_secrets
install_op_cli
extract_secrets
uninstall_op_cli
unset IFS
# Add extra env var that lists which secrets are managed by 1Password so that in a later step
# these can be unset again.
managed_variables_str=$(IFS=','; echo "${managed_variables[*]}")
echo "$managed_variables_var=$managed_variables_str" >> $GITHUB_ENV
+9154 -3796
View File
File diff suppressed because it is too large Load Diff
+15 -23
View File
@@ -1,15 +1,14 @@
{ {
"name": "load-secrets-action", "name": "load-secrets-action",
"version": "4.0.0", "version": "1.2.0",
"description": "Load Secrets from 1Password", "description": "Load Secrets from 1Password",
"type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"directories": { "directories": {
"test": "tests" "test": "tests"
}, },
"scripts": { "scripts": {
"build": "ncc build ./src/index.ts", "build": "ncc build ./src/index.ts",
"build:configure": "ncc build ./configure/index.js -o ./configure/dist",
"build:all": "npm run build && npm run build:configure",
"format": "prettier --ignore-path ./config/.prettierignore", "format": "prettier --ignore-path ./config/.prettierignore",
"format:check": "npm run format -- --check ./", "format:check": "npm run format -- --check ./",
"format:write": "npm run format -- --write ./", "format:write": "npm run format -- --write ./",
@@ -40,29 +39,22 @@
}, },
"homepage": "https://github.com/1Password/load-secrets-action#readme", "homepage": "https://github.com/1Password/load-secrets-action#readme",
"dependencies": { "dependencies": {
"@1password/op-js": "^0.1.11", "@actions/core": "^1.10.0",
"@actions/core": "^3.0.0", "@actions/exec": "^1.1.1"
"@actions/exec": "^3.0.0",
"@actions/tool-cache": "^4.0.0",
"dotenv": "^17.2.2"
},
"overrides": {
"minimatch": "^9.0.7"
}, },
"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.0",
"@types/jest": "^29.5.12", "@types/node": "^18.15.10",
"@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.5.0",
"jest": "^29.7.0", "lint-staged": "^13.2.0",
"lint-staged": "^15.2.2", "ts-jest": "^29.0.5",
"ts-jest": "^29.1.2", "typescript": "^4.9.5"
"typescript": "^5.4.2"
}, },
"eslintConfig": { "eslintConfig": {
"extends": "@1password/eslint-config", "extends": "./node_modules/@1password/front-end-style/eslintrc.yml",
"ignorePatterns": [ "ignorePatterns": [
"coverage/" "coverage/"
], ],
@@ -70,5 +62,5 @@
"project": "./tsconfig.json" "project": "./tsconfig.json"
} }
}, },
"prettier": "@1password/prettier-config" "prettier": "./node_modules/@1password/front-end-style/prettierrc.json"
} }
-14
View File
@@ -1,14 +0,0 @@
module.exports = {
getInput: jest.fn(() => ""),
getBooleanInput: jest.fn(() => false),
setOutput: jest.fn(),
setSecret: jest.fn(),
exportVariable: jest.fn(),
setFailed: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
addPath: jest.fn(),
isDebug: jest.fn(() => false),
};
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
getExecOutput: jest.fn(() => ({
stdout: "MOCK_SECRET",
})),
};
-10
View File
@@ -1,10 +0,0 @@
module.exports = {
downloadTool: jest.fn(),
extractTar: jest.fn(),
extractZip: jest.fn(),
cacheDir: jest.fn<Promise<string>, [string]>(async (dir) => {
await Promise.resolve();
return dir;
}),
find: jest.fn<string, [string, string?, string?]>(() => ""),
};
-7
View File
@@ -1,7 +0,0 @@
export const envConnectHost = "OP_CONNECT_HOST";
export const envConnectToken = "OP_CONNECT_TOKEN";
export const envServiceAccountToken = "OP_SERVICE_ACCOUNT_TOKEN";
export const envManagedVariables = "OP_MANAGED_VARIABLES";
export const envFilePath = "OP_ENV_FILE";
export const authErr = `Authentication error with environment variables: you must set either 1) ${envServiceAccountToken}, or 2) both ${envConnectHost} and ${envConnectToken}.`;
+13 -41
View File
@@ -1,36 +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 { validateCli } from "@1password/op-js"; import * as exec from "@actions/exec";
import { installCliOnGithubActionRunner } from "./op-cli-installer";
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
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 for the CLI
validateAuth();
// Set environment variables from OP_ENV_FILE
const file = process.env[envFilePath];
if (file) {
core.info(`Loading environment variables from file: ${file}`);
dotenv.config({ path: file });
}
// Download and install the CLI
await installCLI();
// Load secrets
await loadSecrets(shouldExportEnv);
} catch (error) { } catch (error) {
// It's possible for the Error constructor to be modified to be anything // It's possible for the Error constructor to be modified to be anything
// in JavaScript, so the following code accounts for this possibility. // in JavaScript, so the following code accounts for this possibility.
@@ -45,16 +29,4 @@ const loadSecretsAction = async () => {
} }
}; };
// This function's name is an exception from the naming convention void run();
// since we refer to the 1Password CLI here.
// eslint-disable-next-line @typescript-eslint/naming-convention
const installCLI = async (): Promise<void> => {
// validateCli checks if there's an existing 1Password CLI installed on the runner.
// If there's no CLI installed, then validateCli will throw an error, which we will use
// as an indicator that we need to execute the installation script.
await validateCli().catch(async () => {
await installCliOnGithubActionRunner();
});
};
void loadSecretsAction();
@@ -1,58 +0,0 @@
import os from "os";
import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
export type SupportedPlatform = Extract<
NodeJS.Platform,
"linux" | "darwin" | "win32"
>;
// maps OS architecture names to 1Password CLI installer architecture names
export const archMap: Record<string, string> = {
ia32: "386",
x64: "amd64",
arm: "arm",
arm64: "arm64",
};
// Builds the download URL for the 1Password CLI based on the platform and version.
export const cliUrlBuilder: Record<
SupportedPlatform,
(version: string, arch?: string) => string
> = {
linux: (version, arch) =>
`https://cache.agilebits.com/dist/1P/op2/pkg/${version}/op_linux_${arch}_${version}.zip`,
darwin: (version) =>
`https://cache.agilebits.com/dist/1P/op2/pkg/${version}/op_apple_universal_${version}.pkg`,
win32: (version, arch) =>
`https://cache.agilebits.com/dist/1P/op2/pkg/${version}/op_windows_${arch}_${version}.zip`,
};
export class CliInstaller {
public readonly version: string;
public readonly arch: string;
public constructor(version: string) {
this.version = version;
this.arch = this.getArch();
}
public async install(url: string): Promise<void> {
console.info(`Downloading 1Password CLI from: ${url}`);
const downloadPath = await tc.downloadTool(url);
console.info("Installing 1Password CLI");
const extractedPath = await tc.extractZip(downloadPath);
core.addPath(extractedPath);
core.info("1Password CLI installed");
}
private getArch(): string {
const arch = archMap[os.arch()];
if (!arch) {
throw new Error("Unsupported architecture");
}
return arch;
}
}
@@ -1 +0,0 @@
export { type Installer, newCliInstaller } from "./installer";
@@ -1,43 +0,0 @@
import os from "os";
import { newCliInstaller } from "./installer";
import { LinuxInstaller } from "./linux";
import { MacOsInstaller } from "./macos";
import { WindowsInstaller } from "./windows";
afterEach(() => {
jest.restoreAllMocks();
});
describe("newCliInstaller", () => {
const version = "1.0.0";
afterEach(() => {
jest.resetAllMocks();
});
it("should return LinuxInstaller for linux platform", () => {
jest.spyOn(os, "platform").mockReturnValue("linux");
const installer = newCliInstaller(version);
expect(installer).toBeInstanceOf(LinuxInstaller);
});
it("should return MacOsInstaller for darwin platform", () => {
jest.spyOn(os, "platform").mockReturnValue("darwin");
const installer = newCliInstaller(version);
expect(installer).toBeInstanceOf(MacOsInstaller);
});
it("should return WindowsInstaller for win32 platform", () => {
jest.spyOn(os, "platform").mockReturnValue("win32");
const installer = newCliInstaller(version);
expect(installer).toBeInstanceOf(WindowsInstaller);
});
it("should throw error for unsupported platform", () => {
jest.spyOn(os, "platform").mockReturnValue("sunos");
expect(() => newCliInstaller(version)).toThrow(
"Unsupported platform: sunos",
);
});
});
@@ -1,23 +0,0 @@
import os from "os";
import { LinuxInstaller } from "./linux";
import { MacOsInstaller } from "./macos";
import { WindowsInstaller } from "./windows";
export interface Installer {
installCli(): Promise<void>;
}
export const newCliInstaller = (version: string): Installer => {
const platform = os.platform();
switch (platform) {
case "linux":
return new LinuxInstaller(version);
case "darwin":
return new MacOsInstaller(version);
case "win32":
return new WindowsInstaller(version);
default:
throw new Error(`Unsupported platform: ${platform}`);
}
};
@@ -1,58 +0,0 @@
import {
ONEPASSWORD_GPG_KEY_FINGERPRINT,
verifyLinuxSignature,
} from "./linux-signature";
describe("verifyLinuxSignature", () => {
const OP_PATH = "/tmp/op";
const SIG_PATH = `${OP_PATH}.sig`;
const CORRECT_FPR = `fpr:::::::::${ONEPASSWORD_GPG_KEY_FINGERPRINT}:\n`;
const WRONG_FPR = `fpr:::::::::DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF:\n`;
const gpgRunner = (...responses: (string | Error)[]) => {
const runner = jest.fn<Promise<string>, [readonly string[]]>();
for (const r of responses) {
if (r instanceof Error) {
runner.mockRejectedValueOnce(r);
} else {
runner.mockResolvedValueOnce(r);
}
}
return runner;
};
const subcommandsCalled = (runner: ReturnType<typeof gpgRunner>) =>
runner.mock.calls.map(([args]: [readonly string[]]) =>
args.find(
(a) => a === "--import" || a === "--list-keys" || a === "--verify",
),
);
it("imports the bundled key and verifies the signature", async () => {
const runner = gpgRunner("", CORRECT_FPR, "");
await expect(
verifyLinuxSignature(OP_PATH, SIG_PATH, runner),
).resolves.toBeUndefined();
expect(subcommandsCalled(runner)).toEqual([
"--import",
"--list-keys",
"--verify",
]);
});
it("throws and skips --verify when the imported key has the wrong fingerprint", async () => {
const runner = gpgRunner("", WRONG_FPR);
await expect(
verifyLinuxSignature(OP_PATH, SIG_PATH, runner),
).rejects.toThrow(/does not match expected/);
expect(subcommandsCalled(runner)).toEqual(["--import", "--list-keys"]);
});
it("throws when gpg --verify rejects the signature", async () => {
const runner = gpgRunner("", CORRECT_FPR, new Error("BAD signature"));
await expect(
verifyLinuxSignature(OP_PATH, SIG_PATH, runner),
).rejects.toThrow(/BAD signature/);
});
});
@@ -1,64 +0,0 @@
import { execFile } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
// 1Password's code-signing GPG key fingerprint. See
// https://www.1password.dev/cli/verify.
export const ONEPASSWORD_GPG_KEY_FINGERPRINT =
"3FEF9748469ADBE15DA7CA80AC2D62742012EA22";
// Bundled 1Password code-signing public key `linux-signing-key.asc` in
// this directory. Bundled to avoid a runtime keyserver/URL dependency.
// Source: https://downloads.1password.com/linux/keys/1password.asc
const ONEPASSWORD_GPG_PUBLIC_KEY_PATH = path.join(
__dirname,
"linux-signing-key.asc",
);
const defaultGpgRunner = async (args: readonly string[]): Promise<string> => {
const { stdout } = await execFileAsync("gpg", args);
return stdout;
};
// Throws unless the binary at opPath carries a valid GPG signature (at
// sigPath) from the pinned 1Password key. The key is bundled with the action
export const verifyLinuxSignature = async (
opPath: string,
sigPath: string,
runGpg: (args: readonly string[]) => Promise<string> = defaultGpgRunner,
): Promise<void> => {
const gpgHome = fs.mkdtempSync(path.join(os.tmpdir(), "op-verify-"));
try {
const baseArgs = ["--homedir", gpgHome, "--batch", "--no-tty"];
// Import the bundled key into the temp keyring.
await runGpg([...baseArgs, "--import", ONEPASSWORD_GPG_PUBLIC_KEY_PATH]);
// Confirm we imported the pinned key.
const keyringListing = await runGpg([
...baseArgs,
"--list-keys",
"--with-colons",
]);
if (!keyringListing.includes(`${ONEPASSWORD_GPG_KEY_FINGERPRINT}:`)) {
throw new Error(
`bundled GPG key does not match expected fingerprint ${ONEPASSWORD_GPG_KEY_FINGERPRINT}.`,
);
}
// Verify op.sig against op using the imported key.
await runGpg([...baseArgs, "--verify", sigPath, opPath]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`1Password CLI signature verification failed: ${message}. ` +
"If 1Password has rotated their GPG signing key, this action needs to be updated — please file an issue at https://github.com/1Password/load-secrets-action/issues.",
);
} finally {
fs.rmSync(gpgHome, { recursive: true, force: true });
}
};
@@ -1,49 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
P4I7jHwe0BN3iNAG1nKbGg==
=+LeX
-----END PGP PUBLIC KEY BLOCK-----
@@ -1,35 +0,0 @@
import os from "os";
import {
archMap,
cliUrlBuilder,
type SupportedPlatform,
} from "./cli-installer";
import { LinuxInstaller } from "./linux";
afterEach(() => {
jest.restoreAllMocks();
});
describe("LinuxInstaller", () => {
const version = "1.2.3";
const arch: NodeJS.Architecture = "arm64";
it("should construct with given version and architecture", () => {
jest.spyOn(os, "arch").mockReturnValue(arch);
const installer = new LinuxInstaller(version);
expect(installer.version).toEqual(version);
expect(installer.arch).toEqual(archMap[arch]);
});
it("should call install with correct URL", async () => {
const installer = new LinuxInstaller(version);
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
await installer.installCli();
const builder = cliUrlBuilder["linux" as SupportedPlatform];
const url = builder(version, installer.arch);
expect(installMock).toHaveBeenCalledWith(url);
});
});
@@ -1,42 +0,0 @@
import * as path from "path";
import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
import {
CliInstaller,
cliUrlBuilder,
type SupportedPlatform,
} from "./cli-installer";
import type { Installer } from "./installer";
import { verifyLinuxSignature } from "./linux-signature";
export class LinuxInstaller extends CliInstaller implements Installer {
private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux
public constructor(version: string) {
super(version);
}
public async installCli(): Promise<void> {
const urlBuilder = cliUrlBuilder[this.platform];
await this.install(urlBuilder(this.version, this.arch));
}
public override async install(url: string): Promise<void> {
console.info(`Downloading 1Password CLI from: ${url}`);
const downloadPath = await tc.downloadTool(url);
console.info("Installing 1Password CLI");
const extractedPath = await tc.extractZip(downloadPath);
core.info("Verifying 1Password CLI signature");
await verifyLinuxSignature(
path.join(extractedPath, "op"),
path.join(extractedPath, "op.sig"),
);
core.info("1Password CLI signature verified");
core.addPath(extractedPath);
core.info("1Password CLI installed");
}
}
@@ -1,57 +0,0 @@
import {
ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS,
APPLE_DEVELOPER_TEAM_ID,
verifyMacOsPackageSignature,
} from "./macos-signature";
const VALID_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[0]!;
const buildPkgutilOutput = ({
teamId = APPLE_DEVELOPER_TEAM_ID,
signerFingerprint = VALID_FINGERPRINT,
}: {
teamId?: string;
signerFingerprint?: string;
} = {}): string => {
const bytes = signerFingerprint.match(/.{2}/g)!;
const fprLines = ` ${bytes.slice(0, 24).join(" ")}\n ${bytes.slice(24).join(" ")}`;
return `Package "op.pkg":
Certificate Chain:
1. Developer ID Installer: AgileBits Inc. (${teamId})
SHA256 Fingerprint:
${fprLines}
------------------------------------------------------------------------
2. Developer ID Certification Authority
`;
};
const pkgutilRunner = (output: string) =>
jest.fn<Promise<string>, [string]>().mockResolvedValue(output);
describe("verifyMacOsPackageSignature", () => {
it("passes for a pkg signed by AgileBits with an allowlisted cert", async () => {
const runner = pkgutilRunner(buildPkgutilOutput());
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).resolves.toBeUndefined();
});
it("throws if the signer is not under the AgileBits team ID", async () => {
const runner = pkgutilRunner(buildPkgutilOutput({ teamId: "ATTACKER" }));
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).rejects.toThrow(/expected developer team ID 2BUA8C4S2C not found/);
});
it("throws if the signer cert fingerprint is not on the allowlist", async () => {
const runner = pkgutilRunner(
buildPkgutilOutput({
signerFingerprint:
"DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
}),
);
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).rejects.toThrow(/not on the allowlist/);
});
});
@@ -1,78 +0,0 @@
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
// See https://www.1password.dev/cli/verify.
export const APPLE_DEVELOPER_TEAM_ID = "2BUA8C4S2C";
// Append-only: old certs stay listed so historical `op` versions still verify.
// See https://www.1password.dev/cli/verify.
export const ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS = [
"CAB578061B0209FB70934DA344EF6FEBCD3279B1C074C54B0D7D555743B9D89F",
"141DD87B2B231211F1440849798007DF621DE6EB3DAB985BC964EE9704C4A1C1",
];
const defaultPkgutilRunner = async (pkgPath: string): Promise<string> => {
const { stdout } = await execFileAsync("pkgutil", [
"--check-signature",
pkgPath,
]);
return stdout;
};
// Returns just entry 1 (the signer cert) from the chain.
const extractSignerCertSection = (pkgutilOutput: string): string | null => {
const chainStart = pkgutilOutput.indexOf("Certificate Chain:");
if (chainStart === -1) {
return null;
}
const chainBody = pkgutilOutput.slice(chainStart);
const secondCert = /\n\s*2\.\s/.exec(chainBody);
return secondCert ? chainBody.slice(0, secondCert.index) : chainBody;
};
const parseSignerFingerprint = (signerSection: string): string | null => {
const match = /SHA256 Fingerprint:\s*\n((?:[ \t]+[0-9A-Fa-f ]+\n?)+)/.exec(
signerSection,
);
const captured = match?.[1];
return captured ? captured.replace(/\s+/g, "").toUpperCase() : null;
};
// Hard-fails if the .pkg at pkgPath is not signed by AgileBits Inc.
// (2BUA8C4S2C) with a certificate on the allowlist above. Must run
// before any extraction of the .pkg contents.
export const verifyMacOsPackageSignature = async (
pkgPath: string,
runPkgutil: (pkgPath: string) => Promise<string> = defaultPkgutilRunner,
): Promise<void> => {
const stdout = await runPkgutil(pkgPath);
const signerSection = extractSignerCertSection(stdout);
if (!signerSection) {
throw new Error(
`1Password CLI signature verification failed: could not locate certificate chain in pkgutil output.\npkgutil output:\n${stdout}`,
);
}
if (!signerSection.includes(`(${APPLE_DEVELOPER_TEAM_ID})`)) {
throw new Error(
`1Password CLI signature verification failed: expected developer team ID ${APPLE_DEVELOPER_TEAM_ID} not found in signer certificate.\npkgutil output:\n${stdout}`,
);
}
const signerFingerprint = parseSignerFingerprint(signerSection);
if (!signerFingerprint) {
throw new Error(
`1Password CLI signature verification failed: could not parse signer cert SHA-256 fingerprint.\npkgutil output:\n${stdout}`,
);
}
if (!ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS.includes(signerFingerprint)) {
throw new Error(
`1Password CLI signature verification failed: signer cert SHA-256 fingerprint ${signerFingerprint} is not on the allowlist. ` +
"If 1Password has rotated their installer signing cert, this action needs to be updated — please file an issue at https://github.com/1Password/load-secrets-action/issues.",
);
}
};
@@ -1,35 +0,0 @@
import os from "os";
import {
archMap,
cliUrlBuilder,
type SupportedPlatform,
} from "./cli-installer";
import { MacOsInstaller } from "./macos";
afterEach(() => {
jest.restoreAllMocks();
});
describe("MacOsInstaller", () => {
const version = "1.2.3";
const arch: NodeJS.Architecture = "x64";
it("should construct with given version and architecture", () => {
jest.spyOn(os, "arch").mockReturnValue(arch);
const installer = new MacOsInstaller(version);
expect(installer.version).toEqual(version);
expect(installer.arch).toEqual(archMap[arch]);
});
it("should call install with correct URL", async () => {
const installer = new MacOsInstaller(version);
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
await installer.installCli();
const builder = cliUrlBuilder["darwin" as SupportedPlatform];
const url = builder(version, installer.arch);
expect(installMock).toHaveBeenCalledWith(url);
});
});
@@ -1,54 +0,0 @@
import { execFile } from "child_process";
import * as fs from "fs";
import * as path from "path";
import { promisify } from "util";
import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
import {
CliInstaller,
cliUrlBuilder,
type SupportedPlatform,
} from "./cli-installer";
import { type Installer } from "./installer";
import { verifyMacOsPackageSignature } from "./macos-signature";
const execFileAsync = promisify(execFile);
export class MacOsInstaller extends CliInstaller implements Installer {
private readonly platform: SupportedPlatform = "darwin"; // Node.js platform identifier for macOS
public constructor(version: string) {
super(version);
}
public async installCli(): Promise<void> {
const urlBuilder = cliUrlBuilder[this.platform];
await this.install(urlBuilder(this.version));
}
// @actions/tool-cache package does not support .pkg files, so we need to handle the installation manually
public override async install(downloadUrl: string): Promise<void> {
console.info(`Downloading 1Password CLI from: ${downloadUrl}`);
const pkgPath = await tc.downloadTool(downloadUrl);
const pkgWithExtension = `${pkgPath}.pkg`;
fs.renameSync(pkgPath, pkgWithExtension);
core.info("Verifying 1Password CLI signature");
await verifyMacOsPackageSignature(pkgWithExtension);
core.info("1Password CLI signature verified");
const expandDir = "temp-pkg";
await execFileAsync("pkgutil", ["--expand", pkgWithExtension, expandDir]);
const payloadPath = path.join(expandDir, "op.pkg", "Payload");
console.info("Installing 1Password CLI");
const cliPath = await tc.extractTar(payloadPath);
core.addPath(cliPath);
fs.rmSync(expandDir, { recursive: true, force: true });
fs.rmSync(pkgPath, { force: true });
core.info("1Password CLI installed");
}
}
@@ -1,42 +0,0 @@
import {
verifyAuthenticodeSignature,
WINDOWS_SIGNER_SUBJECT_CN,
} from "./windows-signature";
describe("verifyAuthenticodeSignature", () => {
const OP_EXE = "C:\\op\\op.exe";
const buildAuthenticodeOutput = ({
status = "Valid",
subject = `CN=${WINDOWS_SIGNER_SUBJECT_CN}, O=Agilebits, C=CA`,
}: { status?: string; subject?: string } = {}): string =>
[`Status=${status}`, `Subject=${subject}`].join("\n") + "\n";
const powershellRunner = (output: string) =>
jest.fn<Promise<string>, [string]>().mockResolvedValue(output);
it("passes for a valid AgileBits-signed binary", async () => {
const runner = powershellRunner(buildAuthenticodeOutput());
await expect(
verifyAuthenticodeSignature(OP_EXE, runner),
).resolves.toBeUndefined();
});
it("throws if Status is not Valid (unsigned or tampered)", async () => {
const runner = powershellRunner(
buildAuthenticodeOutput({ status: "HashMismatch" }),
);
await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow(
/Authenticode status is HashMismatch/,
);
});
it("throws if the signer is not AgileBits", async () => {
const runner = powershellRunner(
buildAuthenticodeOutput({ subject: "CN=Attacker, O=Attacker, C=US" }),
);
await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow(
/does not contain CN=Agilebits/,
);
});
});
@@ -1,60 +0,0 @@
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
// Identifying field of 1Password's Authenticode signing cert for op.exe.
// See https://www.1password.dev/cli/verify.
export const WINDOWS_SIGNER_SUBJECT_CN = "AgilebitsButWrong";
const defaultPowerShellRunner = async (script: string): Promise<string> => {
const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
script,
]);
return stdout;
};
// Verifies op.exe's Authenticode signature against 1Password's signing cert.
// Throws unless the signature is cryptographically valid and the signer is AgileBits.
export const verifyAuthenticodeSignature = async (
opExePath: string,
runPowerShell: (script: string) => Promise<string> = defaultPowerShellRunner,
): Promise<void> => {
const escapedPath = opExePath.replace(/'/g, "''");
const script = [
`$sig = Get-AuthenticodeSignature -FilePath '${escapedPath}'`,
`"Status=$($sig.Status)"`,
`"Subject=$($sig.SignerCertificate.Subject)"`,
].join("; ");
const output = await runPowerShell(script);
const outputLines = output.split("\n").map((l) => l.trim());
const fieldValue = (prefix: string): string | undefined => {
const matchingLine = outputLines.find((l) => l.startsWith(prefix));
if (!matchingLine) {
return undefined;
}
return matchingLine.slice(prefix.length);
};
// Reject unsigned or tampered binaries.
const status = fieldValue("Status=");
if (status !== "Valid") {
throw new Error(
`Authenticode status is ${status ?? "unknown"}, expected Valid.\nGet-AuthenticodeSignature output:\n${output}`,
);
}
// Confirm the signer is AgileBits, not some other publisher.
const subject = fieldValue("Subject=") ?? "";
if (!subject.includes(`CN=${WINDOWS_SIGNER_SUBJECT_CN}`)) {
throw new Error(
`1Password CLI signature verification failed: signer Subject (${subject}) does not contain CN=${WINDOWS_SIGNER_SUBJECT_CN}. ` +
"If 1Password has rotated or renamed their signing identity, this action needs to be updated — please file an issue at https://github.com/1Password/load-secrets-action/issues.",
);
}
};
@@ -1,63 +0,0 @@
import fs from "fs";
import os from "os";
import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
import {
archMap,
cliUrlBuilder,
type SupportedPlatform,
} from "./cli-installer";
import { WindowsInstaller } from "./windows";
jest.mock("fs");
jest.mock("./windows-signature", () => ({
verifyAuthenticodeSignature: jest.fn().mockResolvedValue(undefined),
}));
afterEach(() => {
jest.restoreAllMocks();
});
describe("WindowsInstaller", () => {
const version = "1.2.3";
const arch: NodeJS.Architecture = "x64";
it("should construct with given version and architecture", () => {
jest.spyOn(os, "arch").mockReturnValue(arch);
const installer = new WindowsInstaller(version);
expect(installer.version).toEqual(version);
expect(installer.arch).toEqual(archMap[arch]);
});
it("should call install with correct URL", async () => {
const installer = new WindowsInstaller(version);
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
await installer.installCli();
const builder = cliUrlBuilder["win32" as SupportedPlatform];
const url = builder(version, installer.arch);
expect(installMock).toHaveBeenCalledWith(url);
});
it("should rename downloaded file with .zip extension before extracting", async () => {
const downloadPath = "/tmp/abc-123";
const extractedPath = "/tmp/extracted";
(tc.downloadTool as jest.Mock).mockResolvedValue(downloadPath);
(tc.extractZip as jest.Mock).mockResolvedValue(extractedPath);
const installer = new WindowsInstaller(version);
await installer.installCli();
expect(tc.downloadTool).toHaveBeenCalled();
expect(fs.renameSync).toHaveBeenCalledWith(
downloadPath,
`${downloadPath}.zip`,
);
expect(tc.extractZip).toHaveBeenCalledWith(`${downloadPath}.zip`);
expect(core.addPath).toHaveBeenCalledWith(extractedPath);
});
});
@@ -1,44 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
import {
CliInstaller,
cliUrlBuilder,
type SupportedPlatform,
} from "./cli-installer";
import type { Installer } from "./installer";
import { verifyAuthenticodeSignature } from "./windows-signature";
export class WindowsInstaller extends CliInstaller implements Installer {
private readonly platform: SupportedPlatform = "win32"; // Node.js platform identifier for Windows
public constructor(version: string) {
super(version);
}
public async installCli(): Promise<void> {
const urlBuilder = cliUrlBuilder[this.platform];
await this.install(urlBuilder(this.version, this.arch));
}
// Windows PowerShell's Expand-Archive requires files to have a .zip extension.
// tc.downloadTool saves to a UUID filename with no extension, so we rename it.
public override async install(url: string): Promise<void> {
console.info(`Downloading 1Password CLI from: ${url}`);
const downloadPath = await tc.downloadTool(url);
const zipPath = `${downloadPath}.zip`;
fs.renameSync(downloadPath, zipPath);
console.info("Installing 1Password CLI");
const extractedPath = await tc.extractZip(zipPath);
core.info("Verifying 1Password CLI signature");
await verifyAuthenticodeSignature(path.join(extractedPath, "op.exe"));
core.info("1Password CLI signature verified");
core.addPath(extractedPath);
core.info("1Password CLI installed");
}
}
@@ -1,18 +0,0 @@
import * as core from "@actions/core";
import { ReleaseChannel, VersionResolver } from "../version";
import { newCliInstaller } from "./cli-installer";
// Installs the 1Password CLI on a GitHub Action runner.
export const installCliOnGithubActionRunner = async (
version?: string,
): Promise<void> => {
// Get the version from parameter, if not passed - from the job input. Defaults to latest if no version is provided
const providedVersion =
version || core.getInput("version") || ReleaseChannel.latest;
const versionResolver = new VersionResolver(providedVersion);
await versionResolver.resolve();
const installer = newCliInstaller(versionResolver.get());
await installer.installCli();
};
-81
View File
@@ -1,81 +0,0 @@
import * as core from "@actions/core";
import { newCliInstaller } from "./github-action/cli-installer";
import {
installCliOnGithubActionRunner,
ReleaseChannel,
VersionResolver,
} from "./index";
jest.mock("./github-action/cli-installer", () => ({
newCliInstaller: jest.fn().mockImplementation((_resolved: string) => ({
installCli: jest.fn(),
})),
}));
beforeEach(() => {
jest.restoreAllMocks();
});
describe("installCliOnGithubActionRunner", () => {
it("should defaults to `latest` when nothing is passed", async () => {
jest.spyOn(core, "getInput").mockReturnValue("");
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
jest
.spyOn(VersionResolver.prototype, "get")
.mockReturnValue(ReleaseChannel.latest);
await installCliOnGithubActionRunner();
expect(newCliInstaller).toHaveBeenCalledWith(ReleaseChannel.latest);
});
it("should defaults to `latest` when undefined is passed", async () => {
jest.spyOn(core, "getInput").mockReturnValue("");
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
jest
.spyOn(VersionResolver.prototype, "get")
.mockReturnValue(ReleaseChannel.latest);
await installCliOnGithubActionRunner(undefined);
expect(newCliInstaller).toHaveBeenCalledWith(ReleaseChannel.latest);
});
it("should set provided explicit version", async () => {
const providedVersion = "1.2.3";
jest.spyOn(core, "getInput").mockReturnValue("");
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
jest
.spyOn(VersionResolver.prototype, "get")
.mockReturnValue(providedVersion);
await installCliOnGithubActionRunner(providedVersion);
expect(newCliInstaller).toHaveBeenCalledWith(providedVersion);
});
it("should set version provided as job input", async () => {
const providedVersion = "3.0.0";
jest.spyOn(core, "getInput").mockReturnValue(providedVersion);
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
jest
.spyOn(VersionResolver.prototype, "get")
.mockReturnValue(providedVersion);
await installCliOnGithubActionRunner();
expect(newCliInstaller).toHaveBeenCalledWith(providedVersion);
});
it("should throw error for invalid version", async () => {
const providedVersion = "invalid";
jest.spyOn(core, "getInput").mockReturnValue(providedVersion);
jest.spyOn(VersionResolver.prototype, "resolve").mockResolvedValue();
jest
.spyOn(VersionResolver.prototype, "get")
.mockReturnValue(providedVersion);
await expect(installCliOnGithubActionRunner()).rejects.toThrow();
});
});
-2
View File
@@ -1,2 +0,0 @@
export { installCliOnGithubActionRunner } from "./github-action";
export { ReleaseChannel, VersionResolver } from "./version";
-13
View File
@@ -1,13 +0,0 @@
export enum ReleaseChannel {
latest = "latest",
latestBeta = "latest-beta",
}
export interface VersionResponse {
// eslint disabled next line as CLI2 is expected in getting CLI versions response
/* eslint-disable-next-line @typescript-eslint/naming-convention */
CLI2: {
release: { version: string };
beta: { version: string };
};
}
@@ -1,91 +0,0 @@
import { ReleaseChannel } from "./constants";
import { getLatestVersion } from "./helper";
describe("getLatestVersion", () => {
beforeEach(() => {
jest.restoreAllMocks();
});
it("should return latest stable version", async () => {
const mockResponse = {
// eslint-disable-next-line @typescript-eslint/naming-convention
CLI2: {
release: { version: "2.31.0" },
beta: { version: "2.32.0-beta.01" },
},
};
jest.spyOn(global, "fetch").mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/require-await
json: async () => mockResponse,
} as Response);
const version = await getLatestVersion(ReleaseChannel.latest);
expect(version).toBe("2.31.0");
});
it("should return latest beta version", async () => {
const mockResponse = {
// eslint-disable-next-line @typescript-eslint/naming-convention
CLI2: {
release: { version: "2.31.0" },
beta: { version: "2.32.0-beta.01" },
},
};
jest.spyOn(global, "fetch").mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/require-await
json: async () => mockResponse,
} as Response);
const version = await getLatestVersion(ReleaseChannel.latestBeta);
expect(version).toBe("2.32.0-beta.01");
});
it("should throw if no CLI2 field", async () => {
jest.spyOn(global, "fetch").mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/require-await
json: async () => ({}),
} as Response);
await expect(getLatestVersion(ReleaseChannel.latest)).rejects.toThrow(
`No ${ReleaseChannel.latest} versions found`,
);
});
it("should throw if no stable version found", async () => {
const mockResponse = {
// eslint-disable-next-line @typescript-eslint/naming-convention
CLI2: {
beta: { version: "2.32.0-beta.01" },
},
};
jest.spyOn(global, "fetch").mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/require-await
json: async () => mockResponse,
} as Response);
await expect(getLatestVersion(ReleaseChannel.latest)).rejects.toThrow(
`No ${ReleaseChannel.latest} versions found`,
);
});
it("should throw if no beta version found", async () => {
const mockResponse = {
// eslint-disable-next-line @typescript-eslint/naming-convention
CLI2: {
release: { version: "2.32.0" },
},
};
jest.spyOn(global, "fetch").mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/require-await
json: async () => mockResponse,
} as Response);
await expect(getLatestVersion(ReleaseChannel.latestBeta)).rejects.toThrow(
`No ${ReleaseChannel.latestBeta} versions found`,
);
});
});
-23
View File
@@ -1,23 +0,0 @@
import * as core from "@actions/core";
import { ReleaseChannel, type VersionResponse } from "./constants";
// Returns the latest version of the 1Password CLI based on the specified channel.
export const getLatestVersion = async (
channel: ReleaseChannel,
): Promise<string> => {
core.info(`Getting ${channel} version number`);
const res = await fetch("https://app-updates.agilebits.com/latest");
const json = (await res.json()) as VersionResponse;
const latestStable = json?.CLI2?.release?.version;
const latestBeta = json?.CLI2?.beta?.version;
const version =
channel === ReleaseChannel.latestBeta ? latestBeta : latestStable;
if (!version) {
core.error(`No ${channel} versions found`);
throw new Error(`No ${channel} versions found`);
}
return version;
};
-2
View File
@@ -1,2 +0,0 @@
export { VersionResolver } from "./version-resolver";
export { ReleaseChannel } from "./constants";
@@ -1,45 +0,0 @@
import { describe, expect, it } from "@jest/globals";
import { validateVersion } from "./validate";
describe("validateVersion", () => {
it('should not throw for "latest"', () => {
expect(() => validateVersion("latest")).not.toThrow();
});
it('should not throw for "latest-beta"', () => {
expect(() => validateVersion("latest-beta")).not.toThrow();
});
it('should not throw for valid semver version "2.18.0"', () => {
expect(() => validateVersion("2.18.0")).not.toThrow();
});
it('should throw for partial version "2"', () => {
expect(() => validateVersion("2")).toThrow();
});
it('should throw for partial version "2.1"', () => {
expect(() => validateVersion("2.1")).toThrow();
});
it('should not throw for valid beta "2.19.0-beta.01"', () => {
expect(() => validateVersion("2.19.0-beta.01")).not.toThrow();
});
it('should not throw for valid beta "2.19.3-beta.12"', () => {
expect(() => validateVersion("2.19.3-beta.12")).not.toThrow();
});
it('should not throw for coerced version "v2.19.0"', () => {
expect(() => validateVersion("v2.19.0")).not.toThrow();
});
it('should throw for invalid version "latest-abc"', () => {
expect(() => validateVersion("latest-abc")).toThrow();
});
it("should throw for empty string", () => {
expect(() => validateVersion("")).toThrow();
});
});
-23
View File
@@ -1,23 +0,0 @@
import semver from "semver";
import { ReleaseChannel } from "./constants";
// Validates if the provided version type is a valid enum value or a valid semver version.
export const validateVersion = (input: string): void => {
if (Object.values(ReleaseChannel).includes(input as ReleaseChannel)) {
return;
}
// 1Password beta releases (aka 2.19.0-beta.01) are not semver compliant.
// According to semver, it should be "2.19.0-beta.1".
// That's why we need to normalize them before validating.
// Accepts valid semver versions like "2.18.0" or beta-releases like "2.19.0-beta.01"
// or versions with 'v' prefix like "v2.19.0"
const normalized = input.replace(/-beta\.0*(\d+)/, "-beta.$1");
const normInput = new semver.SemVer(normalized);
if (semver.valid(normInput)) {
return;
}
throw new Error(`Invalid version input: ${input}`);
};
@@ -1,58 +0,0 @@
import { expect } from "@jest/globals";
import { ReleaseChannel } from "./constants";
import { VersionResolver } from "./version-resolver";
describe("VersionResolver", () => {
test("should throw error when invalid version provided", () => {
expect(() => new VersionResolver("vv")).toThrow();
});
test("should throw error when version is empty", () => {
expect(() => new VersionResolver("")).toThrow();
});
test("should throw error for major version only", () => {
expect(() => new VersionResolver("1")).toThrow();
});
test("should throw error for major and minor version only", () => {
expect(() => new VersionResolver("1.0")).toThrow();
});
test("should resolve latest stable version", async () => {
const versionResolver = new VersionResolver(ReleaseChannel.latest);
await versionResolver.resolve();
expect(versionResolver.get()).toBeDefined();
});
test("should resolve latest beta version", async () => {
const versionResolver = new VersionResolver(ReleaseChannel.latestBeta);
await versionResolver.resolve();
expect(versionResolver.get()).toBeDefined();
});
test("should resolve version without 'v' prefix", async () => {
const versionResolver = new VersionResolver("1.0.0");
await versionResolver.resolve();
expect(versionResolver.get()).toBe("v1.0.0");
});
test("should resolve version with 'v' prefix", async () => {
const versionResolver = new VersionResolver("v1.0.0");
await versionResolver.resolve();
expect(versionResolver.get()).toBe("v1.0.0");
});
test("should resolve beta version without 'v' prefix", async () => {
const versionResolver = new VersionResolver("2.19.0-beta.01");
await versionResolver.resolve();
expect(versionResolver.get()).toBe("v2.19.0-beta.01");
});
test("should resolve beta version with 'v' prefix", async () => {
const versionResolver = new VersionResolver("v2.19.0-beta.01");
await versionResolver.resolve();
expect(versionResolver.get()).toBe("v2.19.0-beta.01");
});
});
@@ -1,45 +0,0 @@
import * as core from "@actions/core";
import { ReleaseChannel } from "./constants";
import { getLatestVersion } from "./helper";
import { validateVersion } from "./validate";
export class VersionResolver {
private version: string;
public constructor(version: string) {
this.validate(version);
this.version = version;
}
public get(): string {
return this.version;
}
public async resolve(): Promise<void> {
core.info(`Resolving version: ${this.version}`);
if (!this.version) {
core.error("Version is not provided");
throw new Error("Version is not provided");
}
if (this.isReleaseChannel(this.version)) {
this.version = await getLatestVersion(this.version);
}
// add `v` prefix if not already present
this.version = this.version.startsWith("v")
? this.version
: `v${this.version}`;
}
private validate(version: string) {
core.info(`Validating version number: '${version}'`);
validateVersion(version);
core.info(`Version number '${version}' is valid`);
}
private isReleaseChannel(value: string): value is ReleaseChannel {
return Object.values(ReleaseChannel).includes(value as ReleaseChannel);
}
}
-193
View File
@@ -1,193 +0,0 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { read, setClientInfo } from "@1password/op-js";
import {
extractSecret,
loadSecrets,
unsetPrevious,
validateAuth,
} from "./utils";
import {
authErr,
envConnectHost,
envConnectToken,
envManagedVariables,
envServiceAccountToken,
} from "./constants";
jest.mock("@1password/op-js");
beforeEach(() => {
jest.clearAllMocks();
});
describe("validateAuth", () => {
const testConnectHost = "https://localhost:8000";
const testConnectToken = "token";
const testServiceAccountToken = "ops_token";
beforeEach(() => {
process.env[envConnectHost] = "";
process.env[envConnectToken] = "";
process.env[envServiceAccountToken] = "";
});
it("should throw an error when no config is provided", () => {
expect(validateAuth).toThrow(authErr);
});
it("should throw an error when partial Connect config is provided", () => {
process.env[envConnectHost] = testConnectHost;
expect(validateAuth).toThrow(authErr);
});
it("should be authenticated as a Connect client", () => {
process.env[envConnectHost] = testConnectHost;
process.env[envConnectToken] = testConnectToken;
expect(validateAuth).not.toThrow(authErr);
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
});
it("should be authenticated as a service account", () => {
process.env[envServiceAccountToken] = testServiceAccountToken;
expect(validateAuth).not.toThrow(authErr);
expect(core.info).toHaveBeenCalledWith(
"Authenticated with Service account.",
);
});
it("should prioritize Connect over service account if both are configured", () => {
process.env[envServiceAccountToken] = testServiceAccountToken;
process.env[envConnectHost] = testConnectHost;
process.env[envConnectToken] = testConnectToken;
expect(validateAuth).not.toThrow(authErr);
expect(core.warning).toHaveBeenCalled();
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
});
});
describe("extractSecret", () => {
const envTestSecretEnv = "TEST_SECRET";
const testSecretRef = "op://vault/item/secret";
const testSecretValue = "Secret1@3$";
read.parse = jest.fn().mockReturnValue(testSecretValue);
process.env[envTestSecretEnv] = testSecretRef;
it("should set secret as step output", () => {
extractSecret(envTestSecretEnv, false);
expect(core.exportVariable).not.toHaveBeenCalledWith(
envTestSecretEnv,
testSecretValue,
);
expect(core.setOutput).toHaveBeenCalledWith(
envTestSecretEnv,
testSecretValue,
);
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
});
it("should set secret as environment variable", () => {
extractSecret(envTestSecretEnv, true);
expect(core.exportVariable).toHaveBeenCalledWith(
envTestSecretEnv,
testSecretValue,
);
expect(core.setOutput).not.toHaveBeenCalledWith(
envTestSecretEnv,
testSecretValue,
);
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
});
describe("when secret value is empty string", () => {
const emptySecretValue = "";
beforeEach(() => {
(read.parse as jest.Mock).mockReturnValue(emptySecretValue);
});
afterEach(() => {
(read.parse as jest.Mock).mockReturnValue(testSecretValue);
});
it("should set empty string as step output", () => {
extractSecret(envTestSecretEnv, false);
expect(core.setOutput).toHaveBeenCalledWith(
envTestSecretEnv,
emptySecretValue,
);
expect(core.exportVariable).not.toHaveBeenCalled();
});
it("should set empty string as environment variable", () => {
extractSecret(envTestSecretEnv, true);
expect(core.exportVariable).toHaveBeenCalledWith(
envTestSecretEnv,
emptySecretValue,
);
expect(core.setOutput).not.toHaveBeenCalled();
});
it("should not call setSecret for empty string", () => {
extractSecret(envTestSecretEnv, false);
expect(core.setSecret).not.toHaveBeenCalled();
});
});
});
describe("loadSecrets", () => {
it("sets the client info and gets the executed output", async () => {
await loadSecrets(true);
expect(setClientInfo).toHaveBeenCalledWith({
name: "1Password GitHub Action",
id: "GHA",
});
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
expect(core.exportVariable).toHaveBeenCalledWith(
"OP_MANAGED_VARIABLES",
"MOCK_SECRET",
);
});
it("return early if no env vars with secrets found", async () => {
(exec.getExecOutput as jest.Mock).mockReturnValueOnce({ stdout: "" });
await loadSecrets(true);
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
expect(core.exportVariable).not.toHaveBeenCalled();
});
describe("core.exportVariable", () => {
it("is called when shouldExportEnv is true", async () => {
await loadSecrets(true);
expect(core.exportVariable).toHaveBeenCalledTimes(1);
});
it("is not called when shouldExportEnv is false", async () => {
await loadSecrets(false);
expect(core.exportVariable).not.toHaveBeenCalled();
});
});
});
describe("unsetPrevious", () => {
const testManagedEnv = "TEST_SECRET";
const testSecretValue = "MyS3cr#T";
beforeEach(() => {
process.env[testManagedEnv] = testSecretValue;
process.env[envManagedVariables] = testManagedEnv;
});
it("should unset the environment variable if user wants it", () => {
unsetPrevious();
expect(core.info).toHaveBeenCalledWith("Unsetting previous values ...");
expect(core.info).toHaveBeenCalledWith("Unsetting TEST_SECRET");
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
});
});
-95
View File
@@ -1,95 +0,0 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { read, setClientInfo, semverToInt } from "@1password/op-js";
import { version } from "../package.json";
import {
authErr,
envConnectHost,
envConnectToken,
envServiceAccountToken,
envManagedVariables,
} from "./constants";
export const validateAuth = (): void => {
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
const isServiceAccount = process.env[envServiceAccountToken];
if (isConnect && isServiceAccount) {
core.warning(
"WARNING: Both service account and Connect credentials are provided. Connect credentials will take priority.",
);
}
if (!isConnect && !isServiceAccount) {
throw new Error(authErr);
}
const authType = isConnect ? "Connect" : "Service account";
core.info(`Authenticated with ${authType}.`);
};
export const extractSecret = (
envName: string,
shouldExportEnv: boolean,
): void => {
core.info(`Populating variable: ${envName}`);
const ref = process.env[envName];
if (!ref) {
return;
}
const secretValue = read.parse(ref);
if (secretValue === null || secretValue === undefined) {
return;
}
if (shouldExportEnv) {
core.exportVariable(envName, secretValue);
} else {
core.setOutput(envName, secretValue);
}
// Skip setSecret for empty strings to avoid the warning:
// "Can't add secret mask for empty string in ##[add-mask] command."
if (secretValue) {
core.setSecret(secretValue);
}
};
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
// Pass User-Agent Information to the 1Password CLI
setClientInfo({
name: "1Password GitHub Action",
id: "GHA",
build: semverToInt(version),
});
// Load secrets from environment variables using 1Password CLI.
// Iterate over them to find 1Password references, extract the secret values,
// and make them available in the next steps either as step outputs or as environment variables.
const res = await exec.getExecOutput(`sh -c "op env ls"`);
if (res.stdout === "") {
return;
}
const envs = res.stdout.replace(/\n+$/g, "").split(/\r?\n/);
for (const envName of envs) {
extractSecret(envName, shouldExportEnv);
}
if (shouldExportEnv) {
core.exportVariable(envManagedVariables, envs.join());
}
};
export const unsetPrevious = (): void => {
if (process.env[envManagedVariables]) {
core.info("Unsetting previous values ...");
const managedEnvs = process.env[envManagedVariables].split(",");
for (const envName of managedEnvs) {
core.info(`Unsetting ${envName}`);
core.exportVariable(envName, "");
}
}
};
+5 -19
View File
@@ -9,8 +9,11 @@ assert_env_equals() {
fi fi
} }
readonly SECRET="RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu" assert_env_equals "SECRET" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
MULTILINE_SECRET="$(cat << EOF
assert_env_equals "SECRET_IN_SECTION" "RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLCB0aGlzIGlzIGp1c3QgYSBkdW1teSBzZWNyZXQuIFBsZWFzZSBkb24ndCByZXBvcnQgaXQu"
assert_env_equals "MULTILINE_SECRET" "$(cat << EOF
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLApXaGls RGVhciBzZWN1cml0eSByZXNlYXJjaGVyLApXaGls
ZSB3ZSBkZWVwbHkgYXBwcmVjaWF0ZSB5b3VyIHZp ZSB3ZSBkZWVwbHkgYXBwcmVjaWF0ZSB5b3VyIHZp
@@ -25,20 +28,3 @@ IApTbyBwbGVhc2UgZG9uJ3QgcmVwb3J0IGl0IQo=
-----END PRIVATE KEY----- -----END PRIVATE KEY-----
EOF EOF
)" )"
readonly MULTILINE_SECRET
readonly WEBSITE="www.test.com"
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}"
# WEBSITE/FILE_WEBSITE: required when ASSERT_WEBSITE=true (Service Account), skipped when false (Connect)
if [ "${ASSERT_WEBSITE:-false}" = "true" ]; then
assert_env_equals "WEBSITE" "${WEBSITE}"
assert_env_equals "FILE_WEBSITE" "${WEBSITE}"
fi
-13
View File
@@ -10,18 +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 "WEBSITE"
assert_env_unset "FILE_WEBSITE"
assert_env_unset "TEST_SSH_KEY"
assert_env_unset "FILE_TEST_SSH_KEY"
assert_env_unset "TEST_SSH_KEY_OPENSSH"
assert_env_unset "FILE_TEST_SSH_KEY_OPENSSH"
-26
View File
@@ -1,26 +0,0 @@
#!/bin/bash
set -e
assert_ssh_key_set() {
local var="$1"
local val
val="$(printenv "$var" || true)"
if [ -z "$val" ]; then
echo "Expected $var to be set"
exit 1
fi
[ "$val" = "***" ] && return 0
local line
line="$(echo "$val" | head -1)"
if echo "$var" | grep -q "OPENSSH"; then
echo "$line" | grep -q "OPENSSH" || { echo "Expected $var to start with -----BEGIN OPENSSH PRIVATE KEY-----"; exit 1; }
else
echo "$line" | grep -q "BEGIN.*PRIVATE KEY" || { echo "Expected $var to be a private key"; exit 1; }
fi
echo "$var OK"
}
assert_ssh_key_set "TEST_SSH_KEY"
assert_ssh_key_set "TEST_SSH_KEY_OPENSSH"
assert_ssh_key_set "FILE_TEST_SSH_KEY"
assert_ssh_key_set "FILE_TEST_SSH_KEY_OPENSSH"
+5 -3
View File
@@ -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
} }
} }