From ced7b31ca0956ad787a77862daf1efba3d27d797 Mon Sep 17 00:00:00 2001 From: lsirac Date: Wed, 23 Nov 2022 10:50:55 -0800 Subject: [PATCH 1/8] fix: do not call metadata server if security creds and region are retrievable through environment vars --- src/auth/awsclient.ts | 21 ++++- test/test.awsclient.ts | 182 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 189 insertions(+), 14 deletions(-) diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index d6dae618..750a9924 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -168,7 +168,9 @@ export class AwsClient extends BaseExternalAccountClient { // Initialize AWS request signer if not already initialized. if (!this.awsRequestSigner) { const metadataHeaders: Headers = {}; - if (this.imdsV2SessionTokenUrl) { + // Only retrieve the IMDSv2 session token if both the security credentials and region are + // not retrievable through the environment. + if (this.shouldUseMetadataServer() && this.imdsV2SessionTokenUrl) { metadataHeaders['x-aws-ec2-metadata-token'] = await this.getImdsV2SessionToken(); } @@ -335,4 +337,21 @@ export class AwsClient extends BaseExternalAccountClient { }); return response.data; } + + private shouldUseMetadataServer(): boolean { + return ( + !this.canRetrieveRegionFromEnvironment() || + !this.canRetrieveSecurityCredentialsFromEnvironment() + ); + } + + private canRetrieveRegionFromEnvironment(): boolean { + return !!(process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION']); + } + + private canRetrieveSecurityCredentialsFromEnvironment(): boolean { + return !!( + process.env['AWS_ACCESS_KEY_ID'] && process.env['AWS_SECRET_ACCESS_KEY'] + ); + } } diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts index 583d8329..04e59866 100644 --- a/test/test.awsclient.ts +++ b/test/test.awsclient.ts @@ -55,6 +55,10 @@ describe('AwsClient', () => { 'https://sts.{region}.amazonaws.com?' + 'Action=GetCallerIdentity&Version=2011-06-15', }; + const awsCredentialSourceWithImdsv2 = Object.assign( + {imdsv2_session_token_url: `${metadataBaseUrl}/latest/api/token`}, + awsCredentialSource + ); const awsOptions = { type: 'external_account', audience, @@ -62,6 +66,13 @@ describe('AwsClient', () => { token_url: getTokenUrl(), credential_source: awsCredentialSource, }; + const awsOptionsWithImdsv2 = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: awsCredentialSourceWithImdsv2, + }; const awsOptionsWithSA = Object.assign( { service_account_impersonation_url: getServiceAccountImpersonationUrl(), @@ -385,19 +396,7 @@ describe('AwsClient', () => { .reply(200, awsSecurityCredentials) ); - const credentialSourceWithSessionTokenUrl = Object.assign( - {imdsv2_session_token_url: `${metadataBaseUrl}/latest/api/token`}, - awsCredentialSource - ); - const awsOptionsWithSessionTokenUrl = { - type: 'external_account', - audience, - subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', - token_url: getTokenUrl(), - credential_source: credentialSourceWithSessionTokenUrl, - }; - - const client = new AwsClient(awsOptionsWithSessionTokenUrl); + const client = new AwsClient(awsOptionsWithImdsv2); const subjectToken = await client.retrieveSubjectToken(); assert.deepEqual(subjectToken, expectedSubjectToken); @@ -829,6 +828,163 @@ describe('AwsClient', () => { assert.deepEqual(subjectToken, expectedSubjectTokenNoToken); }); + + it('should resolve on success for permanent creds with imdsv2', async () => { + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + + const scopes: nock.Scope[] = []; + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token-ttl-seconds': '300'}, + }) + .put('/latest/api/token') + .reply(200, awsSessionToken) + ); + + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token': awsSessionToken}, + }) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + ); + + const client = new AwsClient(awsOptionsWithImdsv2); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectTokenNoToken); + scopes.forEach(scope => scope.done()); + }); + + it('should resolve on success for temporary creds with imdsv2', async () => { + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + process.env.AWS_SESSION_TOKEN = token; + + const scopes: nock.Scope[] = []; + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token-ttl-seconds': '300'}, + }) + .put('/latest/api/token') + .reply(200, awsSessionToken) + ); + + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token': awsSessionToken}, + }) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + ); + + const client = new AwsClient(awsOptionsWithImdsv2); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + scopes.forEach(scope => scope.done()); + }); + + it('should not call metadata server with imdsv2 if creds are retrievable through env', async () => { + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + process.env.AWS_REGION = awsRegion; + + const client = new AwsClient(awsOptionsWithImdsv2); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectTokenNoToken); + }); + + it('should call metadata server with imdsv2 if creds are not retrievable through env', async () => { + process.env.AWS_REGION = awsRegion; + + const scopes: nock.Scope[] = []; + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token-ttl-seconds': '300'}, + }) + .put('/latest/api/token') + .reply(200, awsSessionToken) + ); + + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token': awsSessionToken}, + }) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, awsRole) + .get(`/latest/meta-data/iam/security-credentials/${awsRole}`) + .reply(200, awsSecurityCredentials) + ); + + const client = new AwsClient(awsOptionsWithImdsv2); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + scopes.forEach(scope => scope.done()); + }); + + it('should call metadata server with imdsv2 if secret access key is not not retrievable through env', async () => { + process.env.AWS_REGION = awsRegion; + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + + const scopes: nock.Scope[] = []; + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token-ttl-seconds': '300'}, + }) + .put('/latest/api/token') + .reply(200, awsSessionToken) + ); + + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token': awsSessionToken}, + }) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, awsRole) + .get(`/latest/meta-data/iam/security-credentials/${awsRole}`) + .reply(200, awsSecurityCredentials) + ); + + const client = new AwsClient(awsOptionsWithImdsv2); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + scopes.forEach(scope => scope.done()); + }); + + it('should call metadata server with imdsv2 if access key is not not retrievable through env', async () => { + process.env.AWS_DEFAULT_REGION = awsRegion; + process.env.AWS_SECRET_ACCESS_KEY = accessKeyId; + + const scopes: nock.Scope[] = []; + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token-ttl-seconds': '300'}, + }) + .put('/latest/api/token') + .reply(200, awsSessionToken) + ); + + scopes.push( + nock(metadataBaseUrl, { + reqheaders: {'x-aws-ec2-metadata-token': awsSessionToken}, + }) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, awsRole) + .get(`/latest/meta-data/iam/security-credentials/${awsRole}`) + .reply(200, awsSecurityCredentials) + ); + + const client = new AwsClient(awsOptionsWithImdsv2); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + scopes.forEach(scope => scope.done()); + }); }); describe('getAccessToken()', () => { From 531b4b1594ee4ff02f8253b677abdcec20daef0f Mon Sep 17 00:00:00 2001 From: lsirac Date: Wed, 23 Nov 2022 14:04:56 -0800 Subject: [PATCH 2/8] comments --- src/auth/awsclient.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index 750a9924..b2bfdcee 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -346,10 +346,13 @@ export class AwsClient extends BaseExternalAccountClient { } private canRetrieveRegionFromEnvironment(): boolean { + // The AWS region can be provided through AWS_REGION or AWS_DEFAULT_REGION. + // Only one is required. return !!(process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION']); } private canRetrieveSecurityCredentialsFromEnvironment(): boolean { + // Check if both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are available. return !!( process.env['AWS_ACCESS_KEY_ID'] && process.env['AWS_SECRET_ACCESS_KEY'] ); From f5267fc41983339b3c28b0721eead9dfb76dfae4 Mon Sep 17 00:00:00 2001 From: lsirac Date: Wed, 23 Nov 2022 15:16:51 -0800 Subject: [PATCH 3/8] refactor --- src/auth/awsclient.ts | 58 +++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index b2bfdcee..561954bb 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -58,6 +58,15 @@ interface AwsSecurityCredentials { Expiration: string; } +/** + * Interface defining the AWS security-credentials retrieved from environment variables. + */ +interface AwsSecurityCredentialsFromEnvironment { + accessKeyId: string; + secretAccessKey: string; + token?: string; +} + /** * AWS external account client. This is used for AWS workloads, where * AWS STS GetCallerIdentity serialized signed requests are exchanged for @@ -101,7 +110,7 @@ export class AwsClient extends BaseExternalAccountClient { this.awsRequestSigner = null; this.region = ''; - // data validators + // Data validators. this.validateEnvironmentId(); this.validateMetadataServerURLs(); } @@ -170,6 +179,9 @@ export class AwsClient extends BaseExternalAccountClient { const metadataHeaders: Headers = {}; // Only retrieve the IMDSv2 session token if both the security credentials and region are // not retrievable through the environment. + // The credential config contains all the URLs by default but clients may be running this + // where the metadata server is not available and returning the credentials through the environment. + // Removing this check may break them. if (this.shouldUseMetadataServer() && this.imdsV2SessionTokenUrl) { metadataHeaders['x-aws-ec2-metadata-token'] = await this.getImdsV2SessionToken(); @@ -179,16 +191,8 @@ export class AwsClient extends BaseExternalAccountClient { this.awsRequestSigner = new AwsRequestSigner(async () => { // Check environment variables for permanent credentials first. // https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html - if ( - process.env['AWS_ACCESS_KEY_ID'] && - process.env['AWS_SECRET_ACCESS_KEY'] - ) { - return { - accessKeyId: process.env['AWS_ACCESS_KEY_ID']!, - secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY']!, - // This is normally not available for permanent credentials. - token: process.env['AWS_SESSION_TOKEN'], - }; + if (this.securityCredentialsFromEnvironment) { + return this.securityCredentialsFromEnvironment; } // Since the role on a VM can change, we don't need to cache it. const roleName = await this.getAwsRoleName(metadataHeaders); @@ -275,8 +279,8 @@ export class AwsClient extends BaseExternalAccountClient { private async getAwsRegion(headers: Headers): Promise { // Priority order for region determination: // AWS_REGION > AWS_DEFAULT_REGION > metadata server. - if (process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION']) { - return (process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION'])!; + if (this.regionFromEnvironment) { + return this.regionFromEnvironment; } if (!this.regionUrl) { throw new Error( @@ -339,22 +343,34 @@ export class AwsClient extends BaseExternalAccountClient { } private shouldUseMetadataServer(): boolean { + // The metadata server must be used when either the AWS region or AWS security + // credentials cannot be retrieved through their defined environment variables. return ( - !this.canRetrieveRegionFromEnvironment() || - !this.canRetrieveSecurityCredentialsFromEnvironment() + !this.regionFromEnvironment || !this.securityCredentialsFromEnvironment ); } - private canRetrieveRegionFromEnvironment(): boolean { + private get regionFromEnvironment(): string | undefined { // The AWS region can be provided through AWS_REGION or AWS_DEFAULT_REGION. // Only one is required. - return !!(process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION']); + return process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION']; } - private canRetrieveSecurityCredentialsFromEnvironment(): boolean { + private get securityCredentialsFromEnvironment(): + | AwsSecurityCredentialsFromEnvironment + | undefined { + let awsSecurityCredentials; // Check if both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are available. - return !!( - process.env['AWS_ACCESS_KEY_ID'] && process.env['AWS_SECRET_ACCESS_KEY'] - ); + if ( + process.env['AWS_ACCESS_KEY_ID'] && + process.env['AWS_SECRET_ACCESS_KEY'] + ) { + awsSecurityCredentials = { + accessKeyId: process.env['AWS_ACCESS_KEY_ID'], + secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'], + token: process.env['AWS_SESSION_TOKEN'], + }; + } + return awsSecurityCredentials; } } From b4b9a09fa71919d224c7c09250462adf46a62d57 Mon Sep 17 00:00:00 2001 From: lsirac Date: Wed, 23 Nov 2022 15:52:01 -0800 Subject: [PATCH 4/8] review --- src/auth/awsclient.ts | 51 ++++++++++++++---------------------- src/auth/awsrequestsigner.ts | 2 +- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index 561954bb..3ccbae4b 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -14,7 +14,7 @@ import {GaxiosOptions} from 'gaxios'; -import {AwsRequestSigner} from './awsrequestsigner'; +import {AwsRequestSigner, AwsSecurityCredentials} from './awsrequestsigner'; import { BaseExternalAccountClient, BaseExternalAccountClientOptions, @@ -48,7 +48,7 @@ export interface AwsClientOptions extends BaseExternalAccountClientOptions { /** * Interface defining the AWS security-credentials endpoint response. */ -interface AwsSecurityCredentials { +interface AwsSecurityCredentialsResponse { Code: string; LastUpdated: string; Type: string; @@ -58,15 +58,6 @@ interface AwsSecurityCredentials { Expiration: string; } -/** - * Interface defining the AWS security-credentials retrieved from environment variables. - */ -interface AwsSecurityCredentialsFromEnvironment { - accessKeyId: string; - secretAccessKey: string; - token?: string; -} - /** * AWS external account client. This is used for AWS workloads, where * AWS STS GetCallerIdentity serialized signed requests are exchanged for @@ -191,8 +182,8 @@ export class AwsClient extends BaseExternalAccountClient { this.awsRequestSigner = new AwsRequestSigner(async () => { // Check environment variables for permanent credentials first. // https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html - if (this.securityCredentialsFromEnvironment) { - return this.securityCredentialsFromEnvironment; + if (this.securityCredentialsFromEnv) { + return this.securityCredentialsFromEnv; } // Since the role on a VM can change, we don't need to cache it. const roleName = await this.getAwsRoleName(metadataHeaders); @@ -279,8 +270,8 @@ export class AwsClient extends BaseExternalAccountClient { private async getAwsRegion(headers: Headers): Promise { // Priority order for region determination: // AWS_REGION > AWS_DEFAULT_REGION > metadata server. - if (this.regionFromEnvironment) { - return this.regionFromEnvironment; + if (this.regionFromEnv) { + return this.regionFromEnv; } if (!this.regionUrl) { throw new Error( @@ -333,44 +324,40 @@ export class AwsClient extends BaseExternalAccountClient { private async getAwsSecurityCredentials( roleName: string, headers: Headers - ): Promise { - const response = await this.transporter.request({ - url: `${this.securityCredentialsUrl}/${roleName}`, - responseType: 'json', - headers: headers, - }); + ): Promise { + const response = + await this.transporter.request({ + url: `${this.securityCredentialsUrl}/${roleName}`, + responseType: 'json', + headers: headers, + }); return response.data; } private shouldUseMetadataServer(): boolean { // The metadata server must be used when either the AWS region or AWS security // credentials cannot be retrieved through their defined environment variables. - return ( - !this.regionFromEnvironment || !this.securityCredentialsFromEnvironment - ); + return !this.regionFromEnv || !this.securityCredentialsFromEnv; } - private get regionFromEnvironment(): string | undefined { + private get regionFromEnv(): string | undefined { // The AWS region can be provided through AWS_REGION or AWS_DEFAULT_REGION. // Only one is required. return process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION']; } - private get securityCredentialsFromEnvironment(): - | AwsSecurityCredentialsFromEnvironment - | undefined { - let awsSecurityCredentials; - // Check if both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are available. + private get securityCredentialsFromEnv(): AwsSecurityCredentials | null { + // Both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required. if ( process.env['AWS_ACCESS_KEY_ID'] && process.env['AWS_SECRET_ACCESS_KEY'] ) { - awsSecurityCredentials = { + return { accessKeyId: process.env['AWS_ACCESS_KEY_ID'], secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'], token: process.env['AWS_SESSION_TOKEN'], }; } - return awsSecurityCredentials; + return null; } } diff --git a/src/auth/awsrequestsigner.ts b/src/auth/awsrequestsigner.ts index a0e8870c..4bb5a151 100644 --- a/src/auth/awsrequestsigner.ts +++ b/src/auth/awsrequestsigner.ts @@ -40,7 +40,7 @@ interface AwsAuthHeaderMap { * These are either determined from AWS security_credentials endpoint or * AWS environment variables. */ -interface AwsSecurityCredentials { +export interface AwsSecurityCredentials { accessKeyId: string; secretAccessKey: string; token?: string; From aa2b21538eef018308f7477b8b8ac459730d0364 Mon Sep 17 00:00:00 2001 From: lsirac Date: Wed, 23 Nov 2022 15:54:05 -0800 Subject: [PATCH 5/8] fix for consistency --- src/auth/awsclient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index 3ccbae4b..ae628f6c 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -340,10 +340,10 @@ export class AwsClient extends BaseExternalAccountClient { return !this.regionFromEnv || !this.securityCredentialsFromEnv; } - private get regionFromEnv(): string | undefined { + private get regionFromEnv(): string | null { // The AWS region can be provided through AWS_REGION or AWS_DEFAULT_REGION. // Only one is required. - return process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION']; + return process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION'] || null; } private get securityCredentialsFromEnv(): AwsSecurityCredentials | null { From 5bd1f17472f4d87f26421b7ee69e2fcb0327df7f Mon Sep 17 00:00:00 2001 From: lsirac Date: Wed, 23 Nov 2022 15:57:23 -0800 Subject: [PATCH 6/8] lint --- src/auth/awsclient.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index ae628f6c..80911bb5 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -343,7 +343,9 @@ export class AwsClient extends BaseExternalAccountClient { private get regionFromEnv(): string | null { // The AWS region can be provided through AWS_REGION or AWS_DEFAULT_REGION. // Only one is required. - return process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION'] || null; + return ( + process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION'] || null + ); } private get securityCredentialsFromEnv(): AwsSecurityCredentials | null { From f0fc00244f703e505a5f0a7ab170d14e263f608b Mon Sep 17 00:00:00 2001 From: lsirac Date: Wed, 23 Nov 2022 16:40:52 -0800 Subject: [PATCH 7/8] remove docs check --- .github/workflows/ci.yaml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f447b84a..7e9acbca 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,15 +46,3 @@ jobs: node-version: 14 - run: npm install - run: npm run lint - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: 14 - - run: npm install - - run: npm run docs - - uses: JustinBeckwith/linkinator-action@v1 - with: - paths: docs/ From 332e2e1a100d9e7a5925f350fc0430186888bdc7 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Tue, 29 Nov 2022 00:06:21 +0000 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20po?= =?UTF-8?q?st-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- .github/workflows/ci.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7e9acbca..f447b84a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,3 +46,15 @@ jobs: node-version: 14 - run: npm install - run: npm run lint + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 14 + - run: npm install + - run: npm run docs + - uses: JustinBeckwith/linkinator-action@v1 + with: + paths: docs/