Compare commits
120 Commits
v1.0.1
...
ruetz-auto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2722cfb5c8 | ||
|
|
0b5bf555f1 | ||
|
|
cca0f55ece | ||
|
|
baa29d5bd0 | ||
|
|
7ffac7f68a | ||
|
|
1b0b59a7a7 | ||
|
|
da98918135 | ||
|
|
e99f0139fe | ||
|
|
82b420f3b7 | ||
|
|
d36634f96f | ||
|
|
a8494ee438 | ||
|
|
904025a654 | ||
|
|
581a835fb5 | ||
|
|
cf1a288161 | ||
|
|
2792fede48 | ||
|
|
b575844081 | ||
|
|
c7a1c1e3bb | ||
|
|
a2a357a196 | ||
|
|
d1a4e73495 | ||
|
|
83858b7236 | ||
|
|
10ed0757b7 | ||
|
|
539eaa66ee | ||
|
|
08315da4b3 | ||
|
|
0e91b4a315 | ||
|
|
9c2d98ed07 | ||
|
|
d8ac5d7286 | ||
|
|
15d95ae871 | ||
|
|
a48d1fcd00 | ||
|
|
8fd274c5eb | ||
|
|
f4303b27ca | ||
|
|
f4a99d4598 | ||
|
|
9c1afd6054 | ||
|
|
a02ee663cc | ||
|
|
663ac229cb | ||
|
|
9bb44334eb | ||
|
|
1ec261f63f | ||
|
|
2e386ac304 | ||
|
|
0b706bbe43 | ||
|
|
e23df52c69 | ||
|
|
ade3078eb5 | ||
|
|
4ee6567f7b | ||
|
|
27b96b5fd8 | ||
|
|
14687e434a | ||
|
|
1a17146422 | ||
|
|
94dcd16f05 | ||
|
|
4cd70e1a09 | ||
|
|
b30803866e | ||
|
|
feb6ed7c04 | ||
|
|
d669a8ba06 | ||
|
|
936f62b7d9 | ||
|
|
95681075d8 | ||
|
|
0a7975f916 | ||
|
|
ffba2a6966 | ||
|
|
2ee4979efa | ||
|
|
7903600d82 | ||
|
|
fbf9be8f55 | ||
|
|
5a04ae581c | ||
|
|
747c0b5974 | ||
|
|
c0fbfd88d3 | ||
|
|
3f3d1e45cb | ||
|
|
b73c8a7ca6 | ||
|
|
da6de9b6b3 | ||
|
|
7610c5737f | ||
|
|
30b5930e91 | ||
|
|
c2c5cda6a2 | ||
|
|
351ed34750 | ||
|
|
be04d82018 | ||
|
|
8c15b6c54d | ||
|
|
cb83ae04bf | ||
|
|
e3b137e007 | ||
|
|
30def81a24 | ||
|
|
ed1f9a48af | ||
|
|
b1b82d7384 | ||
|
|
a37c95c8d0 | ||
|
|
aaee1916c6 | ||
|
|
c53c263a7e | ||
|
|
7d858c7ad5 | ||
|
|
ce8b31d0b9 | ||
|
|
2ac8886444 | ||
|
|
641b0d93ec | ||
|
|
c27a045581 | ||
|
|
953b51736f | ||
|
|
2f338e16af | ||
|
|
e9bd76c87a | ||
|
|
d5280efa32 | ||
|
|
8d99fc2a1e | ||
|
|
5c5bbcbaf0 | ||
|
|
e1b37a5b1e | ||
|
|
2a214a29d3 | ||
|
|
2a4f64c09d | ||
|
|
df0b228eb9 | ||
|
|
8052f86fc6 | ||
|
|
34bed60a89 | ||
|
|
ada03a0325 | ||
|
|
c7774c7068 | ||
|
|
1263fc888c | ||
|
|
858b6a838e | ||
|
|
b38b493d73 | ||
|
|
bb28cdf0c6 | ||
|
|
302881bfef | ||
|
|
9fdfd04a6f | ||
|
|
b38d01591f | ||
|
|
d78889ad20 | ||
|
|
6c02b8909f | ||
|
|
cb24269fc9 | ||
|
|
300c776a97 | ||
|
|
2faffa0507 | ||
|
|
d50df7cd6d | ||
|
|
a5debe1b2e | ||
|
|
da5dd0865d | ||
|
|
4af3346b6a | ||
|
|
fae9e58c4f | ||
|
|
000522e32f | ||
|
|
4baca64066 | ||
|
|
ac12d2e3c4 | ||
|
|
e8da10d005 | ||
|
|
5add51bcb8 | ||
|
|
08df44393f | ||
|
|
478705935c | ||
|
|
e64093d691 |
9
.github/workflows/lint.yml
vendored
9
.github/workflows/lint.yml
vendored
@@ -5,6 +5,9 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: ShellCheck
|
- name: Run ShellCheck
|
||||||
uses: ludeeus/action-shellcheck@1.1.0
|
uses: ludeeus/action-shellcheck@2.0.0
|
||||||
|
with:
|
||||||
|
ignore_paths: >-
|
||||||
|
.husky
|
||||||
|
|||||||
44
.github/workflows/release.yml
vendored
Normal file
44
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
# TODO: This branch is for PR testing purposes; update branch to "main" if we proceed with this PR.
|
||||||
|
- ruetz-automate-releases
|
||||||
|
# Specify that this "Release" workflow depends on the other workflows below completing successfully.
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Lint", "Run acceptance tests"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read # for checkout
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # to be able to publish a GitHub release
|
||||||
|
issues: write # to be able to comment on released issues
|
||||||
|
pull-requests: write # to be able to comment on released pull requests
|
||||||
|
id-token: write # to enable use of OIDC for npm provenance
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "lts/*"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm clean-install
|
||||||
|
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
|
||||||
|
run: npm audit signatures
|
||||||
|
- name: Build codebase
|
||||||
|
run: npm run build
|
||||||
|
- name: Release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: npx semantic-release
|
||||||
137
.github/workflows/test.yml
vendored
137
.github/workflows/test.yml
vendored
@@ -2,50 +2,145 @@ on: push
|
|||||||
name: Run acceptance tests
|
name: Run acceptance tests
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
unit-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
|
test-with-output-secrets:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
auth: [connect, service-account]
|
||||||
|
exclude:
|
||||||
|
- os: macos-latest
|
||||||
|
auth: connect
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
- name: Launch 1Password Connect instance
|
- name: Launch 1Password Connect instance
|
||||||
|
if: ${{ matrix.auth == 'connect' }}
|
||||||
env:
|
env:
|
||||||
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||||
run: |
|
run: |
|
||||||
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
||||||
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
|
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
|
- name: Configure 1Password Connect
|
||||||
|
if: ${{ matrix.auth == 'connect' }}
|
||||||
uses: ./configure # 1password/load-secrets-action/configure@<version>
|
uses: ./configure # 1password/load-secrets-action/configure@<version>
|
||||||
with:
|
with:
|
||||||
connect-host: http://localhost:8080
|
connect-host: http://localhost:8080
|
||||||
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
connect-token: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||||
- name: Load secrets
|
- 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>
|
uses: ./ # 1password/load-secrets-action@<version>
|
||||||
env:
|
env:
|
||||||
SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/password
|
SECRET: op://acceptance-tests/test-secret/password
|
||||||
SECRET_IN_SECTION: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/test-section/password
|
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
|
||||||
UNMASKED_VALUE: op://v5pz6venw4roosmkzdq2nhpv6u/hrgkzhrlvscomepxlgafb2m3ca/test-section/username
|
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
|
||||||
- name: Load multiline secret
|
|
||||||
uses: ./ # 1password/load-secrets-action@<version>
|
|
||||||
env:
|
|
||||||
MULTILINE_SECRET: op://v5pz6venw4roosmkzdq2nhpv6u/ghtz3jvcc6dqmzc53d3r3eskge/notesPlain
|
|
||||||
- name: Print environment variables with masked secrets
|
|
||||||
run: printenv
|
|
||||||
- name: Assert test secret values
|
- name: Assert test secret values
|
||||||
run: ./tests/assert-env-set.sh
|
run: ./tests/assert-env-set.sh
|
||||||
- name: Remove secrets
|
- name: Remove secrets
|
||||||
uses: ./ # 1password/load-secrets-action@<version>
|
uses: ./ # 1password/load-secrets-action@<version>
|
||||||
with:
|
with:
|
||||||
unset-previous: true
|
unset-previous: true
|
||||||
- name: Print environment variables with secrets removed
|
|
||||||
run: printenv
|
|
||||||
- name: Assert removed secrets
|
- name: Assert removed secrets
|
||||||
run: ./tests/assert-env-unset.sh
|
run: ./tests/assert-env-unset.sh
|
||||||
- name: Load secrets by vault and item titles
|
test-references-with-ids:
|
||||||
uses: ./ # 1password/load-secrets-action@<version>
|
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:
|
env:
|
||||||
SECRET: op://acceptance-tests/test-secret/password
|
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
|
||||||
SECRET_IN_SECTION: op://acceptance-tests/test-secret/test-section/password
|
run: |
|
||||||
MULTILINE_SECRET: op://acceptance-tests/multiline-secret/notesPlain
|
echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json
|
||||||
- name: Print environment variables with masked secrets
|
docker-compose -f tests/fixtures/docker-compose.yml up -d && sleep 10
|
||||||
run: printenv
|
- name: Configure Service account
|
||||||
- name: Assert test secret values again
|
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
|
run: ./tests/assert-env-set.sh
|
||||||
|
|||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
coverage/
|
||||||
|
node_modules/
|
||||||
51
CONTRIBUTING.md
Normal file
51
CONTRIBUTING.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 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)
|
||||||
164
README.md
164
README.md
@@ -1,10 +1,27 @@
|
|||||||
# Load Secrets from 1Password - GitHub Action
|
<!-- Image sourced from https://blog.1password.com/1password-service-accounts/ -->
|
||||||
|
<img alt="" role="img" src="https://blog.1password.com/posts/2023/1password-service-accounts/header.png"/>
|
||||||
|
|
||||||
The action to load secrets from [1Password Connect](https://1password.com/secrets/) into GitHub Actions.
|
<div align="center">
|
||||||
|
<h1>Load Secrets from 1Password - GitHub Action</h1>
|
||||||
|
<p>Provide the secrets your GitHub runner needs from 1Password.</p>
|
||||||
|
<a href="https://developer.1password.com/docs/ci-cd/github-actions">
|
||||||
|
<img alt="Get started" src="https://user-images.githubusercontent.com/45081667/226940040-16d3684b-60f4-4d95-adb2-5757a8f1bc15.png" height="37"/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
Specify right from your workflow YAML which secrets from 1Password should be loaded into your job, and the action will make them available as environment variables for the next steps.
|
---
|
||||||
|
|
||||||
## Usage
|
`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.
|
||||||
|
|
||||||
|
Read more on the [1Password Developer Portal](https://developer.1password.com/docs/ci-cd/github-actions).
|
||||||
|
|
||||||
|
## 🪄 See it in action!
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=kVBl5iQYgSA "Using 1Password Service Accounts with GitHub Actions")
|
||||||
|
|
||||||
|
## ✨ Quickstart
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
on: push
|
on: push
|
||||||
@@ -12,141 +29,32 @@ jobs:
|
|||||||
hello-world:
|
hello-world:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Load secret
|
- name: Load secret
|
||||||
uses: 1password/load-secrets-action@v1
|
uses: 1password/load-secrets-action@v2
|
||||||
|
with:
|
||||||
|
# Export loaded secrets as environment variables
|
||||||
|
export-env: true
|
||||||
env:
|
env:
|
||||||
OP_CONNECT_HOST: <Your Connect instance URL>
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
|
||||||
SECRET: op://app-cicd/hello-world/secret
|
SECRET: op://app-cicd/hello-world/secret
|
||||||
|
|
||||||
- name: Print masked secret
|
- name: Print masked secret
|
||||||
run: echo "Secret: $SECRET"
|
run: 'echo "Secret: $SECRET"'
|
||||||
# Prints: Secret: ***
|
# Prints: Secret: ***
|
||||||
```
|
```
|
||||||
<details>
|
|
||||||
<summary><b>Longer usage example</b></summary>
|
|
||||||
|
|
||||||
```yml
|
## 💙 Community & Support
|
||||||
on: push
|
|
||||||
name: Deploy app
|
|
||||||
|
|
||||||
jobs:
|
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
|
||||||
test:
|
- Join the [Developer Slack workspace](https://join.slack.com/t/1password-devs/shared_invite/zt-1halo11ps-6o9pEv96xZ3LtX_VE0fJQA).
|
||||||
runs-on: ubuntu-latest
|
- Subscribe to the [Developer Newsletter](https://1password.com/dev-subscribe/).
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Configure 1Password Connect
|
## 🔐 Security
|
||||||
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
|
|
||||||
# every single step in the job would then be able to access the token.
|
|
||||||
connect-host: https://1password.acme.com
|
|
||||||
|
|
||||||
- name: Load Docker credentials
|
1Password requests you practice responsible disclosure if you discover a vulnerability.
|
||||||
uses: 1password/load-secrets-action@v1
|
|
||||||
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
|
Please file requests via [**BugCrowd**](https://bugcrowd.com/agilebits).
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ env.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Print environment variables with masked secrets
|
For information about security practices, please visit the [1Password Bug Bounty Program](https://bugcrowd.com/agilebits).
|
||||||
run: printenv
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
push: true
|
|
||||||
tags: acme/app:latest
|
|
||||||
|
|
||||||
- name: Load AWS credentials
|
|
||||||
uses: 1password/load-secrets-action@v1
|
|
||||||
with:
|
|
||||||
# Remove local copies of the Docker credentials, which are not 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, which was
|
|
||||||
# done automatically by the step above
|
|
||||||
run: ./deploy.sh
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Action Inputs
|
|
||||||
|
|
||||||
| Name | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `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, secret fields from 1Password will automatically be masked from the GitHub Actions logs too.
|
|
||||||
A 1Password field is considered 'secret' when it's marked as concealed (which shows as `•••••••` in the 1Password GUI) or when it's a secure note.
|
|
||||||
So if one of these values accidentally gets printed, it'll get replaced with `***`.
|
|
||||||
|
|
||||||
This means that a username or port field for example will not get masked.
|
|
||||||
|
|
||||||
## 1Password Connect Configuration
|
|
||||||
|
|
||||||
To use the action, 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 URL and a Connect token, you can set the `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` variables.
|
|
||||||
|
|
||||||
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@v2
|
|
||||||
|
|
||||||
- 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 | Default | 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 |
|
|
||||||
|
|
||||||
## Supported Runners
|
|
||||||
|
|
||||||
You can run the action on Linux and macOS runners. Windows is currently not supported.
|
|
||||||
|
|||||||
13
action.yml
13
action.yml
@@ -7,11 +7,10 @@ branding:
|
|||||||
inputs:
|
inputs:
|
||||||
unset-previous:
|
unset-previous:
|
||||||
description: Whether to unset environment variables populated by 1Password in earlier job steps
|
description: Whether to unset environment variables populated by 1Password in earlier job steps
|
||||||
default: false
|
default: "false"
|
||||||
|
export-env:
|
||||||
|
description: Export the secrets as environment variables
|
||||||
|
default: "true"
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: "node20"
|
||||||
steps:
|
main: "dist/index.js"
|
||||||
- run: |
|
|
||||||
export INPUT_UNSET_PREVIOUS=${{ inputs.unset-previous }}
|
|
||||||
${{ github.action_path }}/entrypoint.sh
|
|
||||||
shell: bash
|
|
||||||
|
|||||||
4
config/.husky/pre-commit
Executable file
4
config/.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npx lint-staged --config ./config/lint-staged.config.js
|
||||||
4
config/.husky/pre-push
Executable file
4
config/.husky/pre-push
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npm run validate
|
||||||
3
config/.prettierignore
Normal file
3
config/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
28
config/jest.config.js
Normal file
28
config/jest.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const jestConfig = {
|
||||||
|
/**
|
||||||
|
* Jest docs: "We recommend placing the extensions most commonly used in your project
|
||||||
|
* on the left, so if you are using TypeScript, you may want to consider
|
||||||
|
* moving 'ts' to the beginning of the array."
|
||||||
|
*
|
||||||
|
* https://jestjs.io/docs/configuration#modulefileextensions-arraystring
|
||||||
|
*/
|
||||||
|
moduleFileExtensions: ["ts", "js", "json"],
|
||||||
|
rootDir: "../src/",
|
||||||
|
testEnvironment: "node",
|
||||||
|
testRegex: "(/__tests__/.*|(\\.|/)test)\\.ts",
|
||||||
|
transform: {
|
||||||
|
".ts": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
// Note: We shouldn't need to include `isolatedModules` here because it's a deprecated config option in TS 5,
|
||||||
|
// but setting it to `true` fixes the `ESM syntax is not allowed in a CommonJS module when
|
||||||
|
// 'verbatimModuleSyntax' is enabled` error that we're seeing when running our Jest tests.
|
||||||
|
isolatedModules: true,
|
||||||
|
useESM: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default jestConfig;
|
||||||
9
config/lint-staged.config.js
Normal file
9
config/lint-staged.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const lintStagedConfig = {
|
||||||
|
// run formatting and linting on all supported file types
|
||||||
|
"*.{js,json,md,ts,yaml,yml}": "npm run format:write",
|
||||||
|
"*.{js,ts}": ["npm run lint:fix"],
|
||||||
|
// run testing on all supported file types within the src/ directory
|
||||||
|
"src/**/*.{js,ts}": ["npm run test -- --findRelatedTests"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default lintStagedConfig;
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
name: Configure 1Password Connect
|
name: Configure 1Password Connect and service account
|
||||||
description: Persist 1Password Connect host and token for use in next steps.
|
description: Persist 1Password Connect host, token and service account for use in next steps.
|
||||||
author: 1Password
|
author: 1Password
|
||||||
inputs:
|
inputs:
|
||||||
connect-host:
|
connect-host:
|
||||||
description: Your 1Password Connect instance URL
|
description: Your 1Password Connect instance URL
|
||||||
connect-token:
|
connect-token:
|
||||||
description: Token to authenticate to your 1Password Connect instance
|
description: Token to authenticate to your 1Password Connect instance
|
||||||
|
service-account-token:
|
||||||
|
description: Your 1Password service account token
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- run: |
|
- shell: bash
|
||||||
export INPUT_CONNECT_HOST=${{ inputs.connect-host }}
|
env:
|
||||||
export INPUT_CONNECT_TOKEN=${{ inputs.connect-token }}
|
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
|
${{ github.action_path }}/entrypoint.sh
|
||||||
shell: bash
|
|
||||||
|
|||||||
@@ -14,3 +14,8 @@ OP_CONNECT_TOKEN="${INPUT_CONNECT_TOKEN:-$OP_CONNECT_TOKEN}"
|
|||||||
if [ -n "$OP_CONNECT_TOKEN" ]; then
|
if [ -n "$OP_CONNECT_TOKEN" ]; then
|
||||||
echo "OP_CONNECT_TOKEN=$OP_CONNECT_TOKEN" >> $GITHUB_ENV
|
echo "OP_CONNECT_TOKEN=$OP_CONNECT_TOKEN" >> $GITHUB_ENV
|
||||||
fi
|
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
|
||||||
|
|||||||
30203
dist/index.js
vendored
Normal file
30203
dist/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
dist/package.json
vendored
Normal file
3
dist/package.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
155
entrypoint.sh
155
entrypoint.sh
@@ -1,155 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# shellcheck disable=SC2046,SC2001,SC2086
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$OP_CONNECT_TOKEN" ] || [ -z "$OP_CONNECT_HOST" ]; then
|
|
||||||
echo "\$OP_CONNECT_TOKEN and \$OP_CONNECT_HOST must be set"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
managed_variables_var="OP_MANAGED_VARIABLES"
|
|
||||||
IFS=',' read -r -a managed_variables <<< "$(printenv $managed_variables_var)"
|
|
||||||
|
|
||||||
# Unset all secrets managed by 1Password if `unset-previous` is set.
|
|
||||||
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
|
|
||||||
|
|
||||||
curl_headers=(-H "Content-Type: application/json" -H "Authorization: Bearer $OP_CONNECT_TOKEN")
|
|
||||||
|
|
||||||
# Iterate over environment varables to find 1Password references, load the secret values,
|
|
||||||
# and make them available as environment variables in the next steps.
|
|
||||||
IFS=$'\n'
|
|
||||||
for possible_ref in $(printenv | grep "=op://" | grep -v "^#"); do
|
|
||||||
env_var=$(echo "$possible_ref" | cut -d '=' -f1)
|
|
||||||
ref=$(printenv $env_var)
|
|
||||||
|
|
||||||
if [[ ! $ref == "op://"* ]]; then
|
|
||||||
echo "Not really a reference: $ref"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
path=$(echo $ref | sed -e "s/^op:\/\///")
|
|
||||||
if [ $(echo "$path" | tr -cd '/' | wc -c) -lt 2 ]; then
|
|
||||||
echo "Expected path to be in format op://<vault>/<item>[/<section>]/<field>: $ref"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Populating variable: $env_var"
|
|
||||||
|
|
||||||
vault=""
|
|
||||||
item=""
|
|
||||||
section=""
|
|
||||||
field=""
|
|
||||||
i=0
|
|
||||||
IFS="/"
|
|
||||||
for component in $path; do
|
|
||||||
((i+=1))
|
|
||||||
case "$i" in
|
|
||||||
1) vault=$component ;;
|
|
||||||
2) item=$component ;;
|
|
||||||
3) section=$component ;;
|
|
||||||
4) field=$component ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
unset IFS
|
|
||||||
|
|
||||||
# If field is not set, it may have wrongfully been interpreted as the section.
|
|
||||||
if [ -z "$field" ]; then
|
|
||||||
field="$section"
|
|
||||||
section=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $(echo -n $(echo $vault | grep "^[a-z0-9]*$") | wc -c) -ne 26 ]]; then
|
|
||||||
echo "Getting vault ID from vault name: $vault"
|
|
||||||
vault=$(curl -sSf "${curl_headers[@]}" "$OP_CONNECT_HOST/v1/vaults?filter=name%20eq%20%22$vault%22" | jq -r '.[0] | .id')
|
|
||||||
if [ -z "$vault" ]; then
|
|
||||||
echo "Could not find vault ID for vault: $vault"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $(echo -n $(echo $item | grep "^[a-z0-9]*$") | wc -c) -ne 26 ]]; then
|
|
||||||
echo "Getting item ID from vault $vault..."
|
|
||||||
item=$(curl -sSf "${curl_headers[@]}" "$OP_CONNECT_HOST/v1/vaults/$vault/items?filter=title%20eq%20%22$item%22" | jq -r '.[0] | .id')
|
|
||||||
if [ -z "$item" ]; then
|
|
||||||
echo "Could not find item ID for item: $item"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Loading item $item from vault $vault..."
|
|
||||||
item_json=$(curl -sSf "${curl_headers[@]}" "$OP_CONNECT_HOST/v1/vaults/$vault/items/$item")
|
|
||||||
|
|
||||||
jq_field_selector=".id == \"$field\" or .label == \"$field\""
|
|
||||||
jq_section_selector=".section == null"
|
|
||||||
|
|
||||||
# If the reference contains a section, edit the jq selector to take that into account.
|
|
||||||
if [ -n "$section" ]; then
|
|
||||||
echo "Looking for section: $section"
|
|
||||||
section_id=$(echo "$item_json" | jq -r ".sections[] | select(.id == \"$section\" or .label == \"$section\") | .id")
|
|
||||||
jq_section_selector=".section.id == \"$section_id\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
jq_secret_selector="$jq_section_selector and ($jq_field_selector)"
|
|
||||||
|
|
||||||
echo "Looking for field: $field"
|
|
||||||
secret_field_json=$(echo "$item_json" | jq -r "first(.fields[] | select($jq_secret_selector))")
|
|
||||||
|
|
||||||
field_type=$(echo "$secret_field_json" | jq -r '.type')
|
|
||||||
field_purpose=$(echo "$secret_field_json" | jq -r '.purpose')
|
|
||||||
secret_value=$(echo "$secret_field_json" | jq -r '.value')
|
|
||||||
|
|
||||||
if [ -z "$secret_value" ]; then
|
|
||||||
echo "Could not find or access secret $ref"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If the field is marked as concealed or is a note, register a mask
|
|
||||||
# for the secret to prevent accidental log exposure.
|
|
||||||
if [ "$field_type" == "CONCEALED" ] || [ "$field_purpose" == "NOTES" ]; then
|
|
||||||
# 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
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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 16)
|
|
||||||
|
|
||||||
{
|
|
||||||
# Populate env var, using heredoc syntax with generated identifier
|
|
||||||
echo "$env_var<<${random_heredoc_identifier}"
|
|
||||||
echo "$secret_value"
|
|
||||||
echo "${random_heredoc_identifier}"
|
|
||||||
} >> $GITHUB_ENV
|
|
||||||
|
|
||||||
managed_variables+=("$env_var")
|
|
||||||
done
|
|
||||||
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
|
|
||||||
46
install_cli.sh
Executable file
46
install_cli.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Install op-cli
|
||||||
|
install_op_cli() {
|
||||||
|
# Create a temporary directory where the CLI is installed
|
||||||
|
OP_INSTALL_DIR="$(mktemp -d)"
|
||||||
|
if [[ ! -d "$OP_INSTALL_DIR" ]]; then
|
||||||
|
echo "Install dir $OP_INSTALL_DIR not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::debug::OP_INSTALL_DIR: ${OP_INSTALL_DIR}"
|
||||||
|
|
||||||
|
# Get the latest stable version of the CLI
|
||||||
|
CLI_VERSION="v$(curl https://app-updates.agilebits.com/check/1/0/CLI2/en/2.0.0/N -s | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+')"
|
||||||
|
|
||||||
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
|
# Get runner's architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [[ "$(getconf LONG_BIT)" = 32 ]]; then
|
||||||
|
ARCH="386"
|
||||||
|
elif [[ "$ARCH" == "x86_64" ]]; then
|
||||||
|
ARCH="amd64"
|
||||||
|
elif [[ "$ARCH" == "aarch64" ]]; then
|
||||||
|
ARCH="arm64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ARCH" != "386" ]] && [[ "$ARCH" != "amd64" ]] && [[ "$ARCH" != "arm" ]] && [[ "$ARCH" != "arm64" ]]; then
|
||||||
|
echo "Unsupported architecture for the 1Password CLI: $ARCH."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -sSfLo op.zip "https://cache.agilebits.com/dist/1P/op2/pkg/${CLI_VERSION}/op_linux_${ARCH}_${CLI_VERSION}.zip"
|
||||||
|
unzip -od "$OP_INSTALL_DIR" op.zip && rm op.zip
|
||||||
|
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
curl -sSfLo op.pkg "https://cache.agilebits.com/dist/1P/op2/pkg/${CLI_VERSION}/op_apple_universal_${CLI_VERSION}.pkg"
|
||||||
|
pkgutil --expand op.pkg temp-pkg
|
||||||
|
tar -xvf temp-pkg/op.pkg/Payload -C "$OP_INSTALL_DIR"
|
||||||
|
rm -rf temp-pkg && rm op.pkg
|
||||||
|
else
|
||||||
|
echo "Operating system not supported yet for this GitHub Action: $OSTYPE."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_op_cli
|
||||||
8697
package-lock.json
generated
Normal file
8697
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
package.json
Normal file
67
package.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "load-secrets-action",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Load Secrets from 1Password",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"directories": {
|
||||||
|
"test": "tests"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "ncc build ./src/index.ts",
|
||||||
|
"format": "prettier --ignore-path ./config/.prettierignore",
|
||||||
|
"format:check": "npm run format -- --check ./",
|
||||||
|
"format:write": "npm run format -- --write ./",
|
||||||
|
"lint": "eslint ./",
|
||||||
|
"lint:fix": "npm run lint -- --fix",
|
||||||
|
"prepare": "husky install ./config/.husky",
|
||||||
|
"test": "jest --config=./config/jest.config.js",
|
||||||
|
"test:clearcache": "jest --clearCache",
|
||||||
|
"test:coverage": "npm run test -- --coverage",
|
||||||
|
"test:watch": "npm run test -- --watch",
|
||||||
|
"typecheck": "tsc",
|
||||||
|
"validate": "npm run format:check && npm run lint && npm run test:coverage && npm run typecheck && npm run build"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git@github.com:1Password/load-secrets-action.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"actions",
|
||||||
|
"1password",
|
||||||
|
"load secrets",
|
||||||
|
"connect"
|
||||||
|
],
|
||||||
|
"author": "1Password",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/1Password/load-secrets-action/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/1Password/load-secrets-action#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"@1password/op-js": "^0.1.11",
|
||||||
|
"@actions/core": "^1.10.1",
|
||||||
|
"@actions/exec": "^1.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@1password/front-end-style": "^6.0.1",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/node": "^20.11.30",
|
||||||
|
"@vercel/ncc": "^0.38.1",
|
||||||
|
"husky": "^9.0.11",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"lint-staged": "^15.2.2",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
|
"typescript": "^5.4.2"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "./node_modules/@1password/front-end-style/eslintrc.yml",
|
||||||
|
"ignorePatterns": [
|
||||||
|
"coverage/"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"project": "./tsconfig.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prettier": "./node_modules/@1password/front-end-style/prettierrc.json"
|
||||||
|
}
|
||||||
15
release.config.cjs
Normal file
15
release.config.cjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** @type {import('semantic-release').GlobalConfig} */
|
||||||
|
module.exports = {
|
||||||
|
// TODO: This branch is for PR testing purposes; update branch to "main" if we proceed with this PR.
|
||||||
|
branches: ["ruetz-automate-releases"],
|
||||||
|
// TODO: Remove `dryRun` configuration if we proceed with this PR.
|
||||||
|
dryRun: true,
|
||||||
|
plugins: [
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
"@semantic-release/github",
|
||||||
|
],
|
||||||
|
// Use the `https` Git protocol here to prevent semantic-release from erroring
|
||||||
|
// on the SSH protocol used in `repository.url` in the package.json file.
|
||||||
|
repositoryUrl: "https://github.com/1Password/load-secrets-action.git",
|
||||||
|
};
|
||||||
6
src/constants.ts
Normal file
6
src/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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 authErr = `Authentication error with environment variables: you must set either 1) ${envServiceAccountToken}, or 2) both ${envConnectHost} and ${envConnectToken}.`;
|
||||||
67
src/index.ts
Normal file
67
src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import path from "path";
|
||||||
|
import url from "url";
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as exec from "@actions/exec";
|
||||||
|
import { validateCli } from "@1password/op-js";
|
||||||
|
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
|
||||||
|
|
||||||
|
const loadSecretsAction = async () => {
|
||||||
|
try {
|
||||||
|
// Get action inputs
|
||||||
|
const shouldUnsetPrevious = core.getBooleanInput("unset-previous");
|
||||||
|
const shouldExportEnv = core.getBooleanInput("export-env");
|
||||||
|
|
||||||
|
// Unset all secrets managed by 1Password if `unset-previous` is set.
|
||||||
|
if (shouldUnsetPrevious) {
|
||||||
|
unsetPrevious();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that a proper authentication configuration is set for the CLI
|
||||||
|
validateAuth();
|
||||||
|
|
||||||
|
// Download and install the CLI
|
||||||
|
await installCLI();
|
||||||
|
|
||||||
|
// Load secrets
|
||||||
|
await loadSecrets(shouldExportEnv);
|
||||||
|
} catch (error) {
|
||||||
|
// It's possible for the Error constructor to be modified to be anything
|
||||||
|
// in JavaScript, so the following code accounts for this possibility.
|
||||||
|
// https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript
|
||||||
|
let message = "Unknown Error";
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
} else {
|
||||||
|
String(error);
|
||||||
|
}
|
||||||
|
core.setFailed(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// This function's name is an exception from the naming convention
|
||||||
|
// since we refer to the 1Password CLI here.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
const installCLI = async (): Promise<void> => {
|
||||||
|
// validateCli checks if there's an existing 1Password CLI installed on the runner.
|
||||||
|
// If there's no CLI installed, then validateCli will throw an error, which we will use
|
||||||
|
// as an indicator that we need to execute the installation script.
|
||||||
|
await validateCli().catch(async () => {
|
||||||
|
const currentFile = url.fileURLToPath(import.meta.url);
|
||||||
|
const currentDir = path.dirname(currentFile);
|
||||||
|
const parentDir = path.resolve(currentDir, "..");
|
||||||
|
|
||||||
|
// Execute bash script
|
||||||
|
const cmdOut = await exec.getExecOutput(
|
||||||
|
`sh -c "` + parentDir + `/install_cli.sh"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add path to 1Password CLI to $PATH
|
||||||
|
const outArr = cmdOut.stdout.split("\n");
|
||||||
|
if (outArr[0] && process.env.PATH) {
|
||||||
|
const cliPath = outArr[0]?.replace(/^(::debug::OP_INSTALL_DIR: )/, "");
|
||||||
|
core.addPath(cliPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSecretsAction();
|
||||||
164
src/utils.test.ts
Normal file
164
src/utils.test.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as exec from "@actions/exec";
|
||||||
|
import { read, setClientInfo } from "@1password/op-js";
|
||||||
|
import {
|
||||||
|
extractSecret,
|
||||||
|
loadSecrets,
|
||||||
|
unsetPrevious,
|
||||||
|
validateAuth,
|
||||||
|
} from "./utils";
|
||||||
|
import {
|
||||||
|
authErr,
|
||||||
|
envConnectHost,
|
||||||
|
envConnectToken,
|
||||||
|
envManagedVariables,
|
||||||
|
envServiceAccountToken,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
jest.mock("@actions/core");
|
||||||
|
jest.mock("@actions/exec", () => ({
|
||||||
|
getExecOutput: jest.fn(() => ({
|
||||||
|
stdout: "MOCK_SECRET",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
jest.mock("@1password/op-js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateAuth", () => {
|
||||||
|
const testConnectHost = "https://localhost:8000";
|
||||||
|
const testConnectToken = "token";
|
||||||
|
const testServiceAccountToken = "ops_token";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env[envConnectHost] = "";
|
||||||
|
process.env[envConnectToken] = "";
|
||||||
|
process.env[envServiceAccountToken] = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error when no config is provided", () => {
|
||||||
|
expect(validateAuth).toThrowError(authErr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error when partial Connect config is provided", () => {
|
||||||
|
process.env[envConnectHost] = testConnectHost;
|
||||||
|
expect(validateAuth).toThrowError(authErr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be authenticated as a Connect client", () => {
|
||||||
|
process.env[envConnectHost] = testConnectHost;
|
||||||
|
process.env[envConnectToken] = testConnectToken;
|
||||||
|
expect(validateAuth).not.toThrowError(authErr);
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be authenticated as a service account", () => {
|
||||||
|
process.env[envServiceAccountToken] = testServiceAccountToken;
|
||||||
|
expect(validateAuth).not.toThrowError(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.toThrowError(authErr);
|
||||||
|
expect(core.warning).toHaveBeenCalled();
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Authenticated with Connect.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractSecret", () => {
|
||||||
|
const envTestSecretEnv = "TEST_SECRET";
|
||||||
|
const testSecretRef = "op://vault/item/secret";
|
||||||
|
const testSecretValue = "Secret1@3$";
|
||||||
|
|
||||||
|
read.parse = jest.fn().mockReturnValue(testSecretValue);
|
||||||
|
|
||||||
|
process.env[envTestSecretEnv] = testSecretRef;
|
||||||
|
|
||||||
|
it("should set secret as step output", () => {
|
||||||
|
extractSecret(envTestSecretEnv, false);
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setOutput).toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set secret as environment variable", () => {
|
||||||
|
extractSecret(envTestSecretEnv, true);
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setOutput).not.toHaveBeenCalledWith(
|
||||||
|
envTestSecretEnv,
|
||||||
|
testSecretValue,
|
||||||
|
);
|
||||||
|
expect(core.setSecret).toHaveBeenCalledWith(testSecretValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadSecrets", () => {
|
||||||
|
it("sets the client info and gets the executed output", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(setClientInfo).toHaveBeenCalledWith({
|
||||||
|
name: "1Password GitHub Action",
|
||||||
|
id: "GHA",
|
||||||
|
});
|
||||||
|
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||||
|
"OP_MANAGED_VARIABLES",
|
||||||
|
"MOCK_SECRET",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("return early if no env vars with secrets found", async () => {
|
||||||
|
(exec.getExecOutput as jest.Mock).mockReturnValueOnce({ stdout: "" });
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(exec.getExecOutput).toHaveBeenCalledWith('sh -c "op env ls"');
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("core.exportVariable", () => {
|
||||||
|
it("is called when shouldExportEnv is true", async () => {
|
||||||
|
await loadSecrets(true);
|
||||||
|
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not called when shouldExportEnv is false", async () => {
|
||||||
|
await loadSecrets(false);
|
||||||
|
|
||||||
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unsetPrevious", () => {
|
||||||
|
const testManagedEnv = "TEST_SECRET";
|
||||||
|
const testSecretValue = "MyS3cr#T";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env[testManagedEnv] = testSecretValue;
|
||||||
|
process.env[envManagedVariables] = testManagedEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unset the environment variable if user wants it", () => {
|
||||||
|
unsetPrevious();
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Unsetting previous values ...");
|
||||||
|
expect(core.info).toHaveBeenCalledWith("Unsetting TEST_SECRET");
|
||||||
|
expect(core.exportVariable).toHaveBeenCalledWith("TEST_SECRET", "");
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/utils.ts
Normal file
91
src/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import * as exec from "@actions/exec";
|
||||||
|
import { read, setClientInfo, semverToInt } from "@1password/op-js";
|
||||||
|
import { version } from "../package.json";
|
||||||
|
import {
|
||||||
|
authErr,
|
||||||
|
envConnectHost,
|
||||||
|
envConnectToken,
|
||||||
|
envServiceAccountToken,
|
||||||
|
envManagedVariables,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
export const validateAuth = (): void => {
|
||||||
|
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
|
||||||
|
const isServiceAccount = process.env[envServiceAccountToken];
|
||||||
|
|
||||||
|
if (isConnect && isServiceAccount) {
|
||||||
|
core.warning(
|
||||||
|
"WARNING: Both service account and Connect credentials are provided. Connect credentials will take priority.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnect && !isServiceAccount) {
|
||||||
|
throw new Error(authErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authType = isConnect ? "Connect" : "Service account";
|
||||||
|
|
||||||
|
core.info(`Authenticated with ${authType}.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractSecret = (
|
||||||
|
envName: string,
|
||||||
|
shouldExportEnv: boolean,
|
||||||
|
): void => {
|
||||||
|
core.info(`Populating variable: ${envName}`);
|
||||||
|
|
||||||
|
const ref = process.env[envName];
|
||||||
|
if (!ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretValue = read.parse(ref);
|
||||||
|
if (!secretValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldExportEnv) {
|
||||||
|
core.exportVariable(envName, secretValue);
|
||||||
|
} else {
|
||||||
|
core.setOutput(envName, secretValue);
|
||||||
|
}
|
||||||
|
core.setSecret(secretValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
|
||||||
|
// Pass User-Agent Information to the 1Password CLI
|
||||||
|
setClientInfo({
|
||||||
|
name: "1Password GitHub Action",
|
||||||
|
id: "GHA",
|
||||||
|
build: semverToInt(version),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load secrets from environment variables using 1Password CLI.
|
||||||
|
// Iterate over them to find 1Password references, extract the secret values,
|
||||||
|
// and make them available in the next steps either as step outputs or as environment variables.
|
||||||
|
const res = await exec.getExecOutput(`sh -c "op env ls"`);
|
||||||
|
|
||||||
|
if (res.stdout === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envs = res.stdout.replace(/\n+$/g, "").split(/\r?\n/);
|
||||||
|
for (const envName of envs) {
|
||||||
|
extractSecret(envName, shouldExportEnv);
|
||||||
|
}
|
||||||
|
if (shouldExportEnv) {
|
||||||
|
core.exportVariable(envManagedVariables, envs.join());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unsetPrevious = (): void => {
|
||||||
|
if (process.env[envManagedVariables]) {
|
||||||
|
core.info("Unsetting previous values ...");
|
||||||
|
const managedEnvs = process.env[envManagedVariables].split(",");
|
||||||
|
for (const envName of managedEnvs) {
|
||||||
|
core.info(`Unsetting ${envName}`);
|
||||||
|
core.exportVariable(envName, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": false,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noEmit": true,
|
||||||
|
"noEmitOnError": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user